even more refactoring
This commit is contained in:
236
README.md
236
README.md
@@ -176,3 +176,239 @@ curl --cookie cookies.txt http://localhost:8080/cart/add/TEST-SKU-123
|
|||||||
- Always regenerate protobuf Go code after modifying any `.proto` files (messages/cart_actor/control_plane)
|
- Always regenerate protobuf Go code after modifying any `.proto` files (messages/cart_actor/control_plane)
|
||||||
- The generated `messages.pb.go` file should not be edited manually
|
- The generated `messages.pb.go` file should not be edited manually
|
||||||
- Make sure your PATH includes the protoc-gen-go binary location (usually `$GOPATH/bin`)
|
- Make sure your PATH includes the protoc-gen-go binary location (usually `$GOPATH/bin`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
The system is a distributed, sharded (by cart id) actor model implementation:
|
||||||
|
|
||||||
|
- Each cart is a grain (an in‑memory struct `*CartGrain`) that owns and mutates its own state.
|
||||||
|
- A **local grain pool** holds grains owned by the node.
|
||||||
|
- A **synced (cluster) pool** (`SyncedPool`) coordinates multiple nodes and exposes local or remote grains through a uniform interface (`GrainPool`).
|
||||||
|
- All inter‑node communication is gRPC:
|
||||||
|
- Cart mutation & state RPCs (CartActor service).
|
||||||
|
- Control plane RPCs (ControlPlane service) for membership, ownership negotiation, liveness, and graceful shutdown.
|
||||||
|
|
||||||
|
### Key Processes
|
||||||
|
|
||||||
|
1. Client HTTP request (or gRPC client) arrives with a cart identifier (cookie or path).
|
||||||
|
2. The pool resolves ownership:
|
||||||
|
- If local grain exists → use it.
|
||||||
|
- If a remote host is known owner → a remote grain proxy (`RemoteGrainGRPC`) is used; it performs gRPC calls to the owning node.
|
||||||
|
- If ownership is unknown → node attempts to claim ownership (quorum negotiation) and spawns a local grain.
|
||||||
|
3. Mutation is executed via the **mutation registry** (registry wraps domain logic + optional totals recomputation).
|
||||||
|
4. Updated state returned to caller; ownership preserved unless relinquished later (not yet implemented to shed load).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Grain & Mutation Model
|
||||||
|
|
||||||
|
- `CartGrain` holds items, deliveries, pricing aggregates, and checkout/order metadata.
|
||||||
|
- All mutations are registered via `RegisterMutation[T]` with signature:
|
||||||
|
```
|
||||||
|
func(*CartGrain, *T) error
|
||||||
|
```
|
||||||
|
- `WithTotals()` flag triggers automatic recalculation of totals after successful handlers.
|
||||||
|
- The old giant `switch` in `CartGrain.Apply` has been replaced by registry dispatch; unregistered mutations fail fast.
|
||||||
|
- Adding a mutation:
|
||||||
|
1. Define proto message.
|
||||||
|
2. Generate code.
|
||||||
|
3. Register handler (optionally WithTotals).
|
||||||
|
4. Add gRPC RPC + request wrapper if the mutation must be remotely invokable.
|
||||||
|
5. (Optional) Add HTTP endpoint mapping to the mutation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local Grain Pool
|
||||||
|
|
||||||
|
- Manages an in‑memory map `map[CartId]*CartGrain`.
|
||||||
|
- Lazy spawn: first mutation or explicit access triggers `spawn(id)`.
|
||||||
|
- TTL / purge loop periodically removes expired grains unless they changed recently (basic memory pressure management).
|
||||||
|
- Capacity limit (`PoolSize`); oldest expired grain evicted first when full.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Synced (Cluster) Pool
|
||||||
|
|
||||||
|
`SyncedPool` wraps a local pool and tracks:
|
||||||
|
|
||||||
|
- `remoteHosts`: known peer nodes (gRPC connections).
|
||||||
|
- `remoteIndex`: mapping of cart id → remote grain proxy (`RemoteGrainGRPC`) for carts owned elsewhere.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
1. Discovery integration (via a `Discovery` interface) adds/removes hosts.
|
||||||
|
2. Periodic ping health checks (ControlPlane.Ping).
|
||||||
|
3. Ownership negotiation:
|
||||||
|
- On first contention / unknown owner, node calls `ConfirmOwner` on peers to achieve quorum before making a local grain authoritative.
|
||||||
|
4. Remote spawning:
|
||||||
|
- When a remote host reports its cart ids (`GetCartIds`), the pool creates remote proxies for fast routing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remote Grain Proxies
|
||||||
|
|
||||||
|
A `RemoteGrainGRPC` implements the `Grain` interface but delegates:
|
||||||
|
|
||||||
|
- `Apply` → Specific CartActor per‑mutation RPC (e.g., `AddItem`, `RemoveItem`) constructed from the mutation type. (Legacy envelope removed.)
|
||||||
|
- `GetCurrentState` → `CartActor.GetState`.
|
||||||
|
|
||||||
|
Return path:
|
||||||
|
|
||||||
|
1. gRPC reply (CartMutationReply / StateReply) → proto `CartState`.
|
||||||
|
2. `ToCartState` / mapping reconstructs a local `CartGrain` snapshot for callers expecting grain semantics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Control Plane (Inter‑Node Coordination)
|
||||||
|
|
||||||
|
Defined in `proto/control_plane.proto`:
|
||||||
|
|
||||||
|
| RPC | Purpose |
|
||||||
|
|-----|---------|
|
||||||
|
| `Ping` | Liveness; increments missed ping counter if failing. |
|
||||||
|
| `Negotiate` | Merges membership views; used after discovery events. |
|
||||||
|
| `GetCartIds` | Enumerate locally owned carts for remote index seeding. |
|
||||||
|
| `ConfirmOwner` | Quorum acknowledgment for ownership claim. |
|
||||||
|
| `Closing` | Graceful shutdown notice; peers remove host & associated remote grains. |
|
||||||
|
|
||||||
|
### Ownership / Quorum Rules
|
||||||
|
|
||||||
|
- If total participating hosts < 3 → all must accept.
|
||||||
|
- Otherwise majority acceptance (`ok >= total/2`).
|
||||||
|
- On failure → local tentative grain is removed (rollback to avoid split‑brain).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Request / Mutation Flow Examples
|
||||||
|
|
||||||
|
### Local Mutation
|
||||||
|
1. HTTP handler parses request → determines cart id.
|
||||||
|
2. `SyncedPool.Apply`:
|
||||||
|
- Finds local grain (or spawns new after quorum).
|
||||||
|
- Executes registry mutation.
|
||||||
|
3. Totals updated if flagged.
|
||||||
|
4. HTTP response returns updated JSON (via `ToCartState`).
|
||||||
|
|
||||||
|
### Remote Mutation
|
||||||
|
1. `SyncedPool.Apply` sees cart mapped to a remote host.
|
||||||
|
2. Routes to `RemoteGrainGRPC.Apply`.
|
||||||
|
3. Remote node executes mutation locally and returns updated state over gRPC.
|
||||||
|
4. Proxy materializes snapshot locally (not authoritative, read‑only view).
|
||||||
|
|
||||||
|
### Checkout (Side‑Effecting, Non-Pure)
|
||||||
|
- HTTP `/checkout` uses current grain snapshot to build payload (pure function).
|
||||||
|
- Calls Klarna externally (not a mutation).
|
||||||
|
- Applies `InitializeCheckout` mutation to persist reference + status.
|
||||||
|
- Returns Klarna order JSON to client.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scaling & Deployment
|
||||||
|
|
||||||
|
- **Horizontal scaling**: Add more nodes; discovery layer (Kubernetes / service registry) feeds hosts to `SyncedPool`.
|
||||||
|
- **Sharding**: Implicit by cart id hash. Ownership is first-claim with quorum acceptance.
|
||||||
|
- **Hot spots**: A single popular cart remains on one node; for heavy multi-client concurrency, future work could add read replicas or partitioning (not implemented).
|
||||||
|
- **Capacity tuning**: Increase `PoolSize` & memory limits; adjust TTL for stale cart eviction.
|
||||||
|
|
||||||
|
### Adding Nodes
|
||||||
|
1. Node starts gRPC server (CartActor + ControlPlane).
|
||||||
|
2. After brief delay, begins discovery watch; on event:
|
||||||
|
- New host → dial + negotiate → seed remote cart ids.
|
||||||
|
3. Pings maintain health; failed hosts removed (proxies invalidated).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Failure Handling
|
||||||
|
|
||||||
|
| Scenario | Behavior |
|
||||||
|
|----------|----------|
|
||||||
|
| Remote host unreachable | Pings increment `MissedPings`; after threshold host removed. |
|
||||||
|
| Ownership negotiation fails | Tentative local grain discarded. |
|
||||||
|
| gRPC call error on remote mutation | Error bubbled to caller; no local fallback. |
|
||||||
|
| Missing mutation registration | Fast failure with explicit error message. |
|
||||||
|
| Partial checkout (Klarna fails) | No local state mutation for checkout; client sees error; cart remains unchanged. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mutation Registry Summary
|
||||||
|
|
||||||
|
- Central, type-safe registry prevents silent omission.
|
||||||
|
- Each handler:
|
||||||
|
- Validates input.
|
||||||
|
- Mutates `*CartGrain`.
|
||||||
|
- Returns error for rejection.
|
||||||
|
- Automatic totals recomputation reduces boilerplate and consistency risk.
|
||||||
|
- Coverage test (add separately) can enforce all proto mutations are registered.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## gRPC Interfaces
|
||||||
|
|
||||||
|
- **CartActor**: Per-mutation unary RPCs + `GetState`. (Checkout logic intentionally excluded; handled at HTTP layer.)
|
||||||
|
- **ControlPlane**: Cluster coordination (Ping, Negotiate, ConfirmOwner, etc.).
|
||||||
|
|
||||||
|
**Ports** (default / implied):
|
||||||
|
- CartActor & ControlPlane share the same gRPC server/listener (single port, e.g. `:1337`).
|
||||||
|
- Legacy frame/TCP code has been removed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security & Future Enhancements
|
||||||
|
|
||||||
|
| Area | Potential Improvement |
|
||||||
|
|------|------------------------|
|
||||||
|
| Transport Security | Add TLS / mTLS to gRPC servers & clients. |
|
||||||
|
| Auth / RBAC | Intercept CartActor RPCs with auth metadata. |
|
||||||
|
| Backpressure | Rate-limit remote mutation calls per host. |
|
||||||
|
| Observability | Add per-mutation Prometheus metrics & tracing spans. |
|
||||||
|
| Ownership | Add lease timeouts / fencing tokens for stricter guarantees. |
|
||||||
|
| Batch Ops | Introduce batch mutation RPC or streaming updates (WatchState). |
|
||||||
|
| Persistence | Reintroduce event log or snapshot persistence layer if durability required. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a New Node (Operational Checklist)
|
||||||
|
|
||||||
|
1. Deploy binary/container with same proto + registry.
|
||||||
|
2. Expose gRPC port.
|
||||||
|
3. Ensure discovery lists the new host.
|
||||||
|
4. Node dials peers, negotiates membership.
|
||||||
|
5. Remote cart proxies seeded.
|
||||||
|
6. Traffic routed automatically based on ownership.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a New Mutation (Checklist Recap)
|
||||||
|
|
||||||
|
1. Define proto message (+ request wrapper & RPC if remote invocation needed).
|
||||||
|
2. Regenerate protobuf code.
|
||||||
|
3. Implement & register handler (`RegisterMutation`).
|
||||||
|
4. Add client (HTTP/gRPC) endpoint.
|
||||||
|
5. Write unit + integration tests.
|
||||||
|
6. (Optional) Add to coverage test list and docs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## High-Level Data Flow Diagram (Text)
|
||||||
|
|
||||||
|
```
|
||||||
|
Client -> HTTP Handler -> SyncedPool -> (local?) -> Registry -> Grain State
|
||||||
|
\-> (remote?) -> RemoteGrainGRPC -> gRPC -> Remote CartActor -> Registry -> Grain
|
||||||
|
ControlPlane: Discovery Events <-> Negotiation/Ping/ConfirmOwner <-> SyncedPool state
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Likely Cause | Action |
|
||||||
|
|---------|--------------|--------|
|
||||||
|
| New cart every request | Secure cookie over plain HTTP or not sending cookie jar | Disable Secure locally or use HTTPS & proper curl `-b` |
|
||||||
|
| Unsupported mutation error | Missing registry handler | Add `RegisterMutation` for that proto |
|
||||||
|
| Ownership flapping | Quorum failing due to intermittent peers | Investigate `ConfirmOwner` errors / network |
|
||||||
|
| Remote mutation latency | Network / serialization overhead | Consider batching or colocating hot carts |
|
||||||
|
| Checkout returns 500 | Klarna call failed | Inspect logs; no grain state mutated |
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
245
TODO.md
Normal file
245
TODO.md
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
# TODO / Roadmap
|
||||||
|
|
||||||
|
A living roadmap for improving the cart actor system. Focus areas:
|
||||||
|
1. Reliability & correctness
|
||||||
|
2. Simplicity of mutation & ownership flows
|
||||||
|
3. Developer experience (DX)
|
||||||
|
4. Operability (observability, tracing, metrics)
|
||||||
|
5. Performance & scalability
|
||||||
|
6. Security & multi-tenant readiness
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Immediate Next Steps (High-Leverage)
|
||||||
|
|
||||||
|
| Priority | Task | Goal | Effort | Owner | Notes |
|
||||||
|
|----------|------|------|--------|-------|-------|
|
||||||
|
| P0 | Add mutation registry coverage test | Ensure no unregistered mutations silently fail | S | | Failing fast in CI |
|
||||||
|
| P0 | Add decodeJSON helper + 400 mapping for EOF | Reduce noisy 500 logs | S | | Improves client API clarity |
|
||||||
|
| P0 | Regenerate protos & prune unused messages (CreateCheckoutOrder, Checkout RPC remnants) | Eliminate dead types | S | | Avoid confusion |
|
||||||
|
| P0 | Add integration test: multi-node ownership negotiation | Validate quorum logic | M | | Spin up 2–3 nodes ephemeral |
|
||||||
|
| P1 | Export Prometheus metrics for per-mutation counts & latency | Operability | M | | Wrap registry handlers |
|
||||||
|
| P1 | Add graceful shutdown ordering (Closing → wait for acks → stop gRPC) | Reduce in-flight mutation failures | S | | Add context cancellation |
|
||||||
|
| P1 | Add coverage for InitializeCheckout / OrderCreated flows | Checkout reliability | S | | Simulate Klarna stub |
|
||||||
|
| P2 | Add optional batching client (apply multiple mutations locally then persist) | Performance | M | | Only if needed |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Simplification Opportunities
|
||||||
|
|
||||||
|
### A. RemoteGrain Proxy Mapping
|
||||||
|
Current: manual switch building each RPC call.
|
||||||
|
Simplify by:
|
||||||
|
- Generating a thin client adapter from proto RPC descriptors (codegen).
|
||||||
|
- Or using a registry similar to mutation registry but for “outbound call constructors”.
|
||||||
|
Benefit: adding a new mutation = add proto + register server handler + register outbound invoker (no switch edits).
|
||||||
|
|
||||||
|
### B. Ownership Negotiation
|
||||||
|
Current: ad hoc quorum rule in `SyncedPool`.
|
||||||
|
Simplify:
|
||||||
|
- Introduce explicit `OwnershipLease{holder, expiresAt, version}`.
|
||||||
|
- Use monotonic version increment—reject stale ConfirmOwner replies.
|
||||||
|
- Optional: add randomized backoff to reduce thundering herd on contested cart ids.
|
||||||
|
|
||||||
|
### C. CartId Handling
|
||||||
|
Current: ephemeral 16-byte array with trimmed string semantics.
|
||||||
|
Simplify:
|
||||||
|
- Use ULID / UUIDv7 (time-ordered, collision-resistant) for easier external correlation.
|
||||||
|
- Provide helper `NewCartIdString()` and keep internal fixed-size if still desired.
|
||||||
|
|
||||||
|
### D. Mutation Signatures
|
||||||
|
Current: registry assumes `func(*CartGrain, *T) error`.
|
||||||
|
Extension option: allow pure transforms returning a delta struct (for audit/logging):
|
||||||
|
```
|
||||||
|
type MutationResult struct {
|
||||||
|
Changed bool
|
||||||
|
Events []interface{}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Only implement if auditing/event-sourcing reintroduced.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Developer Experience Improvements
|
||||||
|
|
||||||
|
| Task | Rationale | Approach |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| Makefile targets: `make run-single`, `make run-multi N=3` | Faster local cluster spin-up | Docker compose or background “mini cluster” scripts |
|
||||||
|
| Template for new mutation (generator) | Reduce boilerplate | `go:generate` scanning proto for new RPCs |
|
||||||
|
| Lint config (golangci-lint) | Catch subtle issues early | Add `.golangci.yml` |
|
||||||
|
| Pre-commit hook for proto regeneration check | Avoid stale generated code | Script compares git diff after `make protogen` |
|
||||||
|
| Example client (Go + curl snippets auto-generated) | Onboarding | Codegen a markdown from proto comments |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Observability / Metrics / Tracing
|
||||||
|
|
||||||
|
| Area | Metric / Trace | Notes |
|
||||||
|
|------|----------------|-------|
|
||||||
|
| Mutation registry | `cart_mutations_total{type,success}`; duration histogram | Wrap handler |
|
||||||
|
| Ownership negotiation | `cart_ownership_attempts_total{result}` | result=accepted,rejected,timeout |
|
||||||
|
| Remote latency | `cart_remote_mutation_seconds{method}` | Use client interceptors |
|
||||||
|
| Pings | `cart_remote_missed_pings_total{host}` | Already count, expose |
|
||||||
|
| Checkout flow | `checkout_attempts_total`, `checkout_failures_total` | Differentiate Klarna vs internal errors |
|
||||||
|
| Tracing | Span: HTTP handler → SyncedPool.Apply → (Remote?) gRPC → mutation handler | Add OpenTelemetry instrumentation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Performance & Scalability
|
||||||
|
|
||||||
|
| Concern | Idea | Trade-Off |
|
||||||
|
|---------|------|-----------|
|
||||||
|
| High mutation rate on single cart | Introduce optional mutation queue (serialize explicitly) | Slight latency increase per op |
|
||||||
|
| Remote call overhead | Add client-side gRPC pooling & per-host circuit breaker | Complexity vs resilience |
|
||||||
|
| TTL purge efficiency | Use min-heap or timing wheel instead of slice scan | More code, better big-N performance |
|
||||||
|
| Batch network latency | Add `BatchMutate` RPC (list of mutations applied atomically) | Lost single-op simplicity |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Reliability Features
|
||||||
|
|
||||||
|
| Feature | Description | Priority |
|
||||||
|
|---------|-------------|----------|
|
||||||
|
| Lease fencing token | Include `ownership_version` in all remote mutate requests | M |
|
||||||
|
| Retry policy | Limited retry for transient network errors (idempotent mutations only) | L |
|
||||||
|
| Dead host reconciliation | On host removal, proactively attempt re-acquire of its carts | M |
|
||||||
|
| Drain mode | Node marks itself “draining” → refuses new ownership claims | M |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Security & Hardening
|
||||||
|
|
||||||
|
| Area | Next Step | Detail |
|
||||||
|
|------|-----------|--------|
|
||||||
|
| Transport | mTLS on gRPC | Use SPIFFE IDs or simple CA |
|
||||||
|
| AuthN/AuthZ | Interceptor enforcing service token | Inject metadata header |
|
||||||
|
| Input validation | Strengthen JSON decode responses | Disallow unknown fields globally |
|
||||||
|
| Rate limiting | Per-IP / per-cart throttling | Guard hotspot abuse |
|
||||||
|
| Multi-tenancy | Tenant id dimension in cart id or metadata | Partition metrics & ownership |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Testing Strategy Enhancements
|
||||||
|
|
||||||
|
| Gap | Improvement |
|
||||||
|
|-----|------------|
|
||||||
|
| No multi-node integration test in CI | Spin ephemeral in-process servers on randomized ports |
|
||||||
|
| Mutation regression | Table-driven tests auto-discover handlers via registry |
|
||||||
|
| Ownership race | Stress test: concurrent Apply on same new cart id from N goroutines |
|
||||||
|
| Checkout external dependency | Klarna mock server (HTTptest) + deterministic responses |
|
||||||
|
| Fuzzing | Fuzz `BuildCheckoutOrderPayload` & mutation handlers for panics |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Cleanup / Tech Debt
|
||||||
|
|
||||||
|
| Item | Action |
|
||||||
|
|------|--------|
|
||||||
|
| Remove deprecated proto remnants (CreateCheckoutOrder, Checkout RPC) | Delete & regenerate |
|
||||||
|
| Consolidate duplicate tax computations | Single helper with tax config |
|
||||||
|
| Delivery price hard-coded (4900) | Config or pricing strategy interface |
|
||||||
|
| Mixed naming (camel vs snake JSON historically) | Provide stable external API doc; accept old forms if needed |
|
||||||
|
| Manual remote mutation switch (if still present) | Replace with generated outbound registry |
|
||||||
|
| Mixed error responses (string bodies) | Standardize JSON: `{ "error": "...", "code": 400 }` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Potential Future Features
|
||||||
|
|
||||||
|
| Feature | Value | Complexity |
|
||||||
|
|---------|-------|------------|
|
||||||
|
| Streaming `WatchState` RPC | Real-time cart updates for clients | Medium |
|
||||||
|
| Event sourcing / audit log | Replay, analytics, debugging | High |
|
||||||
|
| Promotion / coupon engine plugin | Business extensibility | Medium |
|
||||||
|
| Partial cart reservation / inventory lock | Stock accuracy under concurrency | High |
|
||||||
|
| Multi-currency pricing | Globalization | Medium |
|
||||||
|
| GraphQL facade | Client flexibility | Medium |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Suggested Prioritized Backlog (Condensed)
|
||||||
|
|
||||||
|
1. Coverage test + decode error mapping (P0)
|
||||||
|
2. Proto regeneration & cleanup (P0)
|
||||||
|
3. Metrics wrapper for registry (P1)
|
||||||
|
4. Multi-node ownership integration test (P1)
|
||||||
|
5. Delivery pricing abstraction (P2)
|
||||||
|
6. Lease version in remote RPCs (P2)
|
||||||
|
7. BatchMutate evaluation (P3)
|
||||||
|
8. TLS / auth hardening (P3) if going multi-tenant/public
|
||||||
|
9. Event sourcing (Evaluate after stability) (P4)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Simplifying the Developer Workflow
|
||||||
|
|
||||||
|
| Pain | Simplifier |
|
||||||
|
|------|------------|
|
||||||
|
| Manual mutation boilerplate | Code generator for registry stubs |
|
||||||
|
| Forgetting totals | Enforce WithTotals lint: fail if mutation touches items/deliveries without flag |
|
||||||
|
| Hard to inspect remote ownership | `/internal/ownership` debug endpoint (JSON of local + remoteIndex) |
|
||||||
|
| Hard to see mutation timings | Add `?debug=latency` header to return per-mutation durations |
|
||||||
|
| Cookie dev confusion (Secure flag) | Env var: `DEV_INSECURE_COOKIES=1` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Example: Mutation Codegen Sketch (Future)
|
||||||
|
|
||||||
|
Input: cart_actor.proto
|
||||||
|
Output: `mutation_auto.go`
|
||||||
|
- Detect messages used in RPC wrappers (e.g., `AddItemRequest` → payload field).
|
||||||
|
- Generate `RegisterMutation` template if handler not found.
|
||||||
|
- Mark with `// TODO implement logic`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Risk / Impact Matrix (Abbreviated)
|
||||||
|
|
||||||
|
| Change | Risk | Mitigation |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| Replace remote switch with registry | Possible missing registration → runtime error | Coverage test gating CI |
|
||||||
|
| Lease introduction | Split-brain if version mishandled | Increment + assert monotonic; test race |
|
||||||
|
| BatchMutate | Large atomic operations starving others | Size limits & fair scheduling |
|
||||||
|
| Event sourcing | Storage + replay complexity | Start with append-only log + compaction job |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Contributing Workflow (Proposed)
|
||||||
|
|
||||||
|
1. Add / modify proto → run `make protogen`
|
||||||
|
2. Implement mutation logic → add `RegisterMutation` invocation
|
||||||
|
3. Add/Update tests (unit + integration)
|
||||||
|
4. Run `make verify` (lint, test, coverage, proto diff)
|
||||||
|
5. Open PR (template auto-checklist referencing this TODO)
|
||||||
|
6. Merge requires green CI + coverage threshold
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Open Questions
|
||||||
|
|
||||||
|
| Question | Notes |
|
||||||
|
|----------|-------|
|
||||||
|
| Do we need sticky sessions for HTTP layer scaling? | Currently cart id routing suffices |
|
||||||
|
| Should deliveries prune invalid line references on SetCartRequest? | Inconsistency risk; add optional cleanup |
|
||||||
|
| Is checkout idempotency strict enough? | Multiple create vs update semantics |
|
||||||
|
| Add version field to CartState for optimistic concurrency? | Could enable external CAS writes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Tracking
|
||||||
|
|
||||||
|
Mark any completed tasks with `[x]`:
|
||||||
|
|
||||||
|
- [ ] Coverage test
|
||||||
|
- [ ] Decode helper + 400 mapping
|
||||||
|
- [ ] Proto cleanup
|
||||||
|
- [ ] Registry metrics instrumentation
|
||||||
|
- [ ] Ownership multi-node test
|
||||||
|
- [ ] Lease versioning
|
||||||
|
- [ ] Delivery pricing abstraction
|
||||||
|
- [ ] TLS/mTLS internal
|
||||||
|
- [ ] BatchMutate design doc
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Last updated: roadmap draft – refine after first metrics & scaling test run._
|
||||||
61
amqp-order-handler.go
Normal file
61
amqp-order-handler.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AmqpOrderHandler struct {
|
||||||
|
Url string
|
||||||
|
Connection *amqp.Connection
|
||||||
|
Channel *amqp.Channel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AmqpOrderHandler) Connect() error {
|
||||||
|
conn, err := amqp.Dial(h.Url)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to RabbitMQ: %w", err)
|
||||||
|
}
|
||||||
|
h.Connection = conn
|
||||||
|
|
||||||
|
ch, err := conn.Channel()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open a channel: %w", err)
|
||||||
|
}
|
||||||
|
h.Channel = ch
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AmqpOrderHandler) Close() error {
|
||||||
|
if h.Channel != nil {
|
||||||
|
h.Channel.Close()
|
||||||
|
}
|
||||||
|
if h.Connection != nil {
|
||||||
|
return h.Connection.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AmqpOrderHandler) OrderCompleted(body []byte) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := h.Channel.PublishWithContext(ctx,
|
||||||
|
"orders", // exchange
|
||||||
|
"new", // routing key
|
||||||
|
false, // mandatory
|
||||||
|
false, // immediate
|
||||||
|
amqp.Publishing{
|
||||||
|
ContentType: "application/json",
|
||||||
|
Body: body,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to publish a message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
234
cart-grain.go
234
cart-grain.go
@@ -1,11 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"slices"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
messages "git.tornberg.me/go-cart-actor/proto"
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
@@ -265,235 +262,14 @@ func GetTaxAmount(total int64, tax int) int64 {
|
|||||||
func (c *CartGrain) Apply(content interface{}, isReplay bool) (*CartGrain, error) {
|
func (c *CartGrain) Apply(content interface{}, isReplay bool) (*CartGrain, error) {
|
||||||
grainMutations.Inc()
|
grainMutations.Inc()
|
||||||
|
|
||||||
switch msg := content.(type) {
|
updated, err := ApplyRegistered(c, content)
|
||||||
|
|
||||||
case *messages.SetCartRequest:
|
|
||||||
c.mu.Lock()
|
|
||||||
c.Items = make([]*CartItem, 0, len(msg.Items))
|
|
||||||
c.mu.Unlock()
|
|
||||||
for _, it := range msg.Items {
|
|
||||||
c.AddItem(it.Sku, int(it.Quantity), it.Country, it.StoreId)
|
|
||||||
}
|
|
||||||
|
|
||||||
case *messages.AddRequest:
|
|
||||||
if existing, found := c.FindItemWithSku(msg.Sku); found {
|
|
||||||
existing.Quantity += int(msg.Quantity)
|
|
||||||
c.UpdateTotals()
|
|
||||||
} else {
|
|
||||||
return c.AddItem(msg.Sku, int(msg.Quantity), msg.Country, msg.StoreId)
|
|
||||||
}
|
|
||||||
|
|
||||||
case *messages.AddItem:
|
|
||||||
if msg.Quantity < 1 {
|
|
||||||
return nil, fmt.Errorf("invalid quantity")
|
|
||||||
}
|
|
||||||
if existing, found := c.FindItemWithSku(msg.Sku); found {
|
|
||||||
existing.Quantity += int(msg.Quantity)
|
|
||||||
c.UpdateTotals()
|
|
||||||
} else {
|
|
||||||
c.mu.Lock()
|
|
||||||
c.lastItemId++
|
|
||||||
tax := 2500
|
|
||||||
if msg.Tax > 0 {
|
|
||||||
tax = int(msg.Tax)
|
|
||||||
}
|
|
||||||
taxAmount := GetTaxAmount(msg.Price, tax)
|
|
||||||
c.Items = append(c.Items, &CartItem{
|
|
||||||
Id: c.lastItemId,
|
|
||||||
ItemId: int(msg.ItemId),
|
|
||||||
Quantity: int(msg.Quantity),
|
|
||||||
Sku: msg.Sku,
|
|
||||||
Name: msg.Name,
|
|
||||||
Price: msg.Price,
|
|
||||||
TotalPrice: msg.Price * int64(msg.Quantity),
|
|
||||||
TotalTax: int64(taxAmount * int64(msg.Quantity)),
|
|
||||||
Image: msg.Image,
|
|
||||||
Stock: StockStatus(msg.Stock),
|
|
||||||
Disclaimer: msg.Disclaimer,
|
|
||||||
Brand: msg.Brand,
|
|
||||||
Category: msg.Category,
|
|
||||||
Category2: msg.Category2,
|
|
||||||
Category3: msg.Category3,
|
|
||||||
Category4: msg.Category4,
|
|
||||||
Category5: msg.Category5,
|
|
||||||
OrgPrice: msg.OrgPrice,
|
|
||||||
ArticleType: msg.ArticleType,
|
|
||||||
Outlet: msg.Outlet,
|
|
||||||
SellerId: msg.SellerId,
|
|
||||||
SellerName: msg.SellerName,
|
|
||||||
Tax: int(taxAmount),
|
|
||||||
TaxRate: tax,
|
|
||||||
StoreId: msg.StoreId,
|
|
||||||
})
|
|
||||||
c.UpdateTotals()
|
|
||||||
c.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
case *messages.ChangeQuantity:
|
|
||||||
for i, item := range c.Items {
|
|
||||||
if item.Id == int(msg.Id) {
|
|
||||||
if msg.Quantity <= 0 {
|
|
||||||
c.Items = append(c.Items[:i], c.Items[i+1:]...)
|
|
||||||
} else {
|
|
||||||
item.Quantity = int(msg.Quantity)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.UpdateTotals()
|
|
||||||
|
|
||||||
case *messages.RemoveItem:
|
|
||||||
newItems := make([]*CartItem, 0, len(c.Items))
|
|
||||||
for _, it := range c.Items {
|
|
||||||
if it.Id != int(msg.Id) {
|
|
||||||
newItems = append(newItems, it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.Items = newItems
|
|
||||||
c.UpdateTotals()
|
|
||||||
|
|
||||||
case *messages.SetDelivery:
|
|
||||||
c.lastDeliveryId++
|
|
||||||
items := make([]int, 0)
|
|
||||||
withDelivery := c.ItemsWithDelivery()
|
|
||||||
if len(msg.Items) == 0 {
|
|
||||||
items = append(items, c.ItemsWithoutDelivery()...)
|
|
||||||
} else {
|
|
||||||
for _, id := range msg.Items {
|
|
||||||
for _, it := range c.Items {
|
|
||||||
if it.Id == int(id) {
|
|
||||||
if slices.Contains(withDelivery, it.Id) {
|
|
||||||
return nil, fmt.Errorf("item already has delivery")
|
|
||||||
}
|
|
||||||
items = append(items, int(it.Id))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(items) > 0 {
|
|
||||||
c.Deliveries = append(c.Deliveries, &CartDelivery{
|
|
||||||
Id: c.lastDeliveryId,
|
|
||||||
Provider: msg.Provider,
|
|
||||||
PickupPoint: msg.PickupPoint,
|
|
||||||
Price: 4900,
|
|
||||||
Items: items,
|
|
||||||
})
|
|
||||||
c.UpdateTotals()
|
|
||||||
}
|
|
||||||
|
|
||||||
case *messages.RemoveDelivery:
|
|
||||||
dels := make([]*CartDelivery, 0, len(c.Deliveries))
|
|
||||||
for _, d := range c.Deliveries {
|
|
||||||
if d.Id == int(msg.Id) {
|
|
||||||
c.TotalPrice -= d.Price
|
|
||||||
} else {
|
|
||||||
dels = append(dels, d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.Deliveries = dels
|
|
||||||
c.UpdateTotals()
|
|
||||||
|
|
||||||
case *messages.SetPickupPoint:
|
|
||||||
for _, d := range c.Deliveries {
|
|
||||||
if d.Id == int(msg.DeliveryId) {
|
|
||||||
d.PickupPoint = &messages.PickupPoint{
|
|
||||||
Id: msg.Id,
|
|
||||||
Address: msg.Address,
|
|
||||||
City: msg.City,
|
|
||||||
Zip: msg.Zip,
|
|
||||||
Country: msg.Country,
|
|
||||||
Name: msg.Name,
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case *messages.CreateCheckoutOrder:
|
|
||||||
orderLines := make([]*Line, 0, len(c.Items))
|
|
||||||
c.PaymentInProgress = true
|
|
||||||
c.Processing = true
|
|
||||||
for _, item := range c.Items {
|
|
||||||
orderLines = append(orderLines, &Line{
|
|
||||||
Type: "physical",
|
|
||||||
Reference: item.Sku,
|
|
||||||
Name: item.Name,
|
|
||||||
Quantity: item.Quantity,
|
|
||||||
UnitPrice: int(item.Price),
|
|
||||||
TaxRate: 2500,
|
|
||||||
QuantityUnit: "st",
|
|
||||||
TotalAmount: int(item.TotalPrice),
|
|
||||||
TotalTaxAmount: int(item.TotalTax),
|
|
||||||
ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", item.Image),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for _, line := range c.Deliveries {
|
|
||||||
if line.Price > 0 {
|
|
||||||
orderLines = append(orderLines, &Line{
|
|
||||||
Type: "shipping_fee",
|
|
||||||
Reference: line.Provider,
|
|
||||||
Name: "Delivery",
|
|
||||||
Quantity: 1,
|
|
||||||
UnitPrice: int(line.Price),
|
|
||||||
TaxRate: 2500,
|
|
||||||
QuantityUnit: "st",
|
|
||||||
TotalAmount: int(line.Price),
|
|
||||||
TotalTaxAmount: int(GetTaxAmount(line.Price, 2500)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
order := CheckoutOrder{
|
|
||||||
PurchaseCountry: "SE",
|
|
||||||
PurchaseCurrency: "SEK",
|
|
||||||
Locale: "sv-se",
|
|
||||||
OrderAmount: int(c.TotalPrice),
|
|
||||||
OrderTaxAmount: int(c.TotalTax),
|
|
||||||
OrderLines: orderLines,
|
|
||||||
MerchantReference1: c.Id.String(),
|
|
||||||
MerchantURLS: &CheckoutMerchantURLS{
|
|
||||||
Terms: msg.Terms,
|
|
||||||
Checkout: msg.Checkout,
|
|
||||||
Confirmation: msg.Confirmation,
|
|
||||||
Validation: msg.Validation,
|
|
||||||
Push: msg.Push,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
orderPayload, err := json.Marshal(order)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if err == ErrMutationNotRegistered {
|
||||||
|
return nil, fmt.Errorf("unsupported mutation type %T (not registered)", content)
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var klarnaOrder *CheckoutOrder
|
return updated, nil
|
||||||
var klarnaError error
|
|
||||||
if c.OrderReference != "" {
|
|
||||||
log.Printf("Updating order id %s", c.OrderReference)
|
|
||||||
klarnaOrder, klarnaError = KlarnaInstance.UpdateOrder(c.OrderReference, bytes.NewReader(orderPayload))
|
|
||||||
} else {
|
|
||||||
klarnaOrder, klarnaError = KlarnaInstance.CreateOrder(bytes.NewReader(orderPayload))
|
|
||||||
}
|
|
||||||
if klarnaError != nil {
|
|
||||||
log.Printf("error from klarna: %v", klarnaError)
|
|
||||||
return nil, klarnaError
|
|
||||||
}
|
|
||||||
if c.OrderReference == "" {
|
|
||||||
c.OrderReference = klarnaOrder.ID
|
|
||||||
c.PaymentStatus = klarnaOrder.Status
|
|
||||||
}
|
|
||||||
// This originally returned a FrameWithPayload; now returns the grain state.
|
|
||||||
// The caller (gRPC handler) is responsible for wrapping this.
|
|
||||||
return c, nil
|
|
||||||
|
|
||||||
case *messages.OrderCreated:
|
|
||||||
c.OrderReference = msg.OrderId
|
|
||||||
c.PaymentStatus = msg.Status
|
|
||||||
c.PaymentInProgress = false
|
|
||||||
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported mutation type %T", content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// (Optional) Append to new storage mechanism here if still required.
|
|
||||||
|
|
||||||
return c, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CartGrain) UpdateTotals() {
|
func (c *CartGrain) UpdateTotals() {
|
||||||
|
|||||||
119
checkout_builder.go
Normal file
119
checkout_builder.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CheckoutMeta carries the external / URL metadata required to build a
|
||||||
|
// Klarna CheckoutOrder from a CartGrain snapshot. It deliberately excludes
|
||||||
|
// any Klarna-specific response fields (HTML snippet, client token, etc.).
|
||||||
|
type CheckoutMeta struct {
|
||||||
|
Terms string
|
||||||
|
Checkout string
|
||||||
|
Confirmation string
|
||||||
|
Validation string
|
||||||
|
Push string
|
||||||
|
Country string
|
||||||
|
Currency string // optional override (defaults to "SEK" if empty)
|
||||||
|
Locale string // optional override (defaults to "sv-se" if empty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildCheckoutOrderPayload converts the current cart grain + meta information
|
||||||
|
// into a CheckoutOrder domain struct and returns its JSON-serialized payload
|
||||||
|
// (to send to Klarna) alongside the structured CheckoutOrder object.
|
||||||
|
//
|
||||||
|
// This function is PURE: it does not perform any network I/O or mutate the
|
||||||
|
// grain. The caller is responsible for:
|
||||||
|
//
|
||||||
|
// 1. Choosing whether to create or update the Klarna order.
|
||||||
|
// 2. Invoking KlarnaClient.CreateOrder / UpdateOrder with the returned payload.
|
||||||
|
// 3. Applying an InitializeCheckout mutation (or equivalent) with the
|
||||||
|
// resulting Klarna order id + status.
|
||||||
|
//
|
||||||
|
// If you later need to support different tax rates per line, you can extend
|
||||||
|
// CartItem / Delivery to expose that data and propagate it here.
|
||||||
|
func BuildCheckoutOrderPayload(grain *CartGrain, meta *CheckoutMeta) ([]byte, *CheckoutOrder, error) {
|
||||||
|
if grain == nil {
|
||||||
|
return nil, nil, fmt.Errorf("nil grain")
|
||||||
|
}
|
||||||
|
if meta == nil {
|
||||||
|
return nil, nil, fmt.Errorf("nil checkout meta")
|
||||||
|
}
|
||||||
|
|
||||||
|
currency := meta.Currency
|
||||||
|
if currency == "" {
|
||||||
|
currency = "SEK"
|
||||||
|
}
|
||||||
|
locale := meta.Locale
|
||||||
|
if locale == "" {
|
||||||
|
locale = "sv-se"
|
||||||
|
}
|
||||||
|
country := meta.Country
|
||||||
|
if country == "" {
|
||||||
|
country = "SE" // sensible default; adjust if multi-country support changes
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := make([]*Line, 0, len(grain.Items)+len(grain.Deliveries))
|
||||||
|
|
||||||
|
// Item lines
|
||||||
|
for _, it := range grain.Items {
|
||||||
|
if it == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, &Line{
|
||||||
|
Type: "physical",
|
||||||
|
Reference: it.Sku,
|
||||||
|
Name: it.Name,
|
||||||
|
Quantity: it.Quantity,
|
||||||
|
UnitPrice: int(it.Price),
|
||||||
|
TaxRate: 2500, // TODO: derive if variable tax rates are introduced
|
||||||
|
QuantityUnit: "st",
|
||||||
|
TotalAmount: int(it.TotalPrice),
|
||||||
|
TotalTaxAmount: int(it.TotalTax),
|
||||||
|
ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", it.Image),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delivery lines
|
||||||
|
for _, d := range grain.Deliveries {
|
||||||
|
if d == nil || d.Price <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, &Line{
|
||||||
|
Type: "shipping_fee",
|
||||||
|
Reference: d.Provider,
|
||||||
|
Name: "Delivery",
|
||||||
|
Quantity: 1,
|
||||||
|
UnitPrice: int(d.Price),
|
||||||
|
TaxRate: 2500,
|
||||||
|
QuantityUnit: "st",
|
||||||
|
TotalAmount: int(d.Price),
|
||||||
|
TotalTaxAmount: int(GetTaxAmount(d.Price, 2500)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
order := &CheckoutOrder{
|
||||||
|
PurchaseCountry: country,
|
||||||
|
PurchaseCurrency: currency,
|
||||||
|
Locale: locale,
|
||||||
|
OrderAmount: int(grain.TotalPrice),
|
||||||
|
OrderTaxAmount: int(grain.TotalTax),
|
||||||
|
OrderLines: lines,
|
||||||
|
MerchantReference1: grain.Id.String(),
|
||||||
|
MerchantURLS: &CheckoutMerchantURLS{
|
||||||
|
Terms: meta.Terms,
|
||||||
|
Checkout: meta.Checkout,
|
||||||
|
Confirmation: meta.Confirmation,
|
||||||
|
Validation: meta.Validation,
|
||||||
|
Push: meta.Push,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(order)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("marshal checkout order: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload, order, nil
|
||||||
|
}
|
||||||
5
cookies.txt
Normal file
5
cookies.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Netscape HTTP Cookie File
|
||||||
|
# https://curl.se/docs/http-cookies.html
|
||||||
|
# This file was generated by libcurl! Edit at your own risk.
|
||||||
|
|
||||||
|
#HttpOnly_localhost FALSE / FALSE 1761304670 cartid 4393545184291837
|
||||||
1
go.mod
1
go.mod
@@ -6,6 +6,7 @@ require (
|
|||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548
|
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548
|
||||||
github.com/prometheus/client_golang v1.23.2
|
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/grpc v1.76.0
|
||||||
google.golang.org/protobuf v1.36.10
|
google.golang.org/protobuf v1.36.10
|
||||||
k8s.io/api v0.34.1
|
k8s.io/api v0.34.1
|
||||||
|
|||||||
6
go.sum
6
go.sum
@@ -70,10 +70,14 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
|
|||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548 h1:lkxVz5lNPlU78El49cx1c3Rxo/bp7Gbzp2BjSRFgg1U=
|
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/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548/go.mod h1:k4lo5gFYb3AqrgftlraCnv95xvjB/w8udRqJQJ12mAE=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -100,6 +104,8 @@ github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oE
|
|||||||
github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q=
|
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 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||||
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
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 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type GrainPool interface {
|
type GrainPool interface {
|
||||||
Process(id CartId, mutations ...interface{}) (*CartGrain, error)
|
Apply(id CartId, mutation interface{}) (*CartGrain, error)
|
||||||
Get(id CartId) (*CartGrain, error)
|
Get(id CartId) (*CartGrain, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,18 +142,12 @@ func (p *GrainLocalPool) GetGrain(id CartId) (*CartGrain, error) {
|
|||||||
return grain, err
|
return grain, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *GrainLocalPool) Process(id CartId, mutations ...interface{}) (*CartGrain, error) {
|
func (p *GrainLocalPool) Apply(id CartId, mutation interface{}) (*CartGrain, error) {
|
||||||
grain, err := p.GetGrain(id)
|
grain, err := p.GetGrain(id)
|
||||||
var result *CartGrain
|
if err != nil || grain == nil {
|
||||||
if err == nil && grain != nil {
|
return nil, err
|
||||||
for _, m := range mutations {
|
|
||||||
result, err = grain.Apply(m, false)
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
return grain.Apply(mutation, false)
|
||||||
}
|
|
||||||
return result, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *GrainLocalPool) Get(id CartId) (*CartGrain, error) {
|
func (p *GrainLocalPool) Get(id CartId) (*CartGrain, error) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// TestCartActorMutationAndState validates end-to-end gRPC mutation + state retrieval
|
// TestCartActorMutationAndState validates end-to-end gRPC mutation + state retrieval
|
||||||
// against a locally started gRPC server (single-node scenario).
|
// against a locally started gRPC server (single-node scenario).
|
||||||
// This test uses the oneof MutationEnvelope directly to avoid hitting external product
|
// This test uses the new per-mutation AddItem RPC (breaking v2 API) to avoid external product fetch logic
|
||||||
// fetching logic (FetchItem) which would require network I/O.
|
// fetching logic (FetchItem) which would require network I/O.
|
||||||
func TestCartActorMutationAndState(t *testing.T) {
|
func TestCartActorMutationAndState(t *testing.T) {
|
||||||
// Setup local grain pool + synced pool (no discovery, single host)
|
// Setup local grain pool + synced pool (no discovery, single host)
|
||||||
@@ -60,32 +60,29 @@ func TestCartActorMutationAndState(t *testing.T) {
|
|||||||
Country: "se",
|
Country: "se",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build oneof envelope directly (no legacy handler/enum)
|
// Issue AddItem RPC directly (breaking v2 API)
|
||||||
envelope := &messages.MutationEnvelope{
|
addResp, err := cartClient.AddItem(context.Background(), &messages.AddItemRequest{
|
||||||
CartId: cartID,
|
CartId: cartID,
|
||||||
ClientTimestamp: time.Now().Unix(),
|
ClientTimestamp: time.Now().Unix(),
|
||||||
Mutation: &messages.MutationEnvelope_AddItem{
|
Payload: addItem,
|
||||||
AddItem: addItem,
|
})
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Issue Mutate RPC
|
|
||||||
mutResp, err := cartClient.Mutate(context.Background(), envelope)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Mutate RPC error: %v", err)
|
t.Fatalf("AddItem RPC error: %v", err)
|
||||||
}
|
}
|
||||||
if mutResp.StatusCode != 200 {
|
if addResp.StatusCode != 200 {
|
||||||
t.Fatalf("Mutate returned non-200 status: %d, error: %s", mutResp.StatusCode, mutResp.GetError())
|
t.Fatalf("AddItem returned non-200 status: %d, error: %s", addResp.StatusCode, addResp.GetError())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the response state
|
// Validate the response state (from AddItem)
|
||||||
state := mutResp.GetState()
|
state := addResp.GetState()
|
||||||
if state == nil {
|
if state == nil {
|
||||||
t.Fatalf("Mutate response state is nil")
|
t.Fatalf("AddItem response state is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// (Removed obsolete Mutate response handling)
|
||||||
|
|
||||||
if len(state.Items) != 1 {
|
if len(state.Items) != 1 {
|
||||||
t.Fatalf("Expected 1 item after mutation, got %d", len(state.Items))
|
t.Fatalf("Expected 1 item after AddItem, got %d", len(state.Items))
|
||||||
}
|
}
|
||||||
if state.Items[0].Sku != "test-sku" {
|
if state.Items[0].Sku != "test-sku" {
|
||||||
t.Fatalf("Unexpected item SKU: %s", state.Items[0].Sku)
|
t.Fatalf("Unexpected item SKU: %s", state.Items[0].Sku)
|
||||||
|
|||||||
166
grpc_server.go
166
grpc_server.go
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
messages "git.tornberg.me/go-cart-actor/proto"
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
@@ -29,63 +30,126 @@ func NewCartActorGRPCServer(pool GrainPool, syncedPool *SyncedPool) *cartActorGR
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mutate applies a mutation from an envelope to the corresponding cart grain.
|
// applyMutation routes a single cart mutation to the target grain (used by per-mutation RPC handlers).
|
||||||
func (s *cartActorGRPCServer) Mutate(ctx context.Context, envelope *messages.MutationEnvelope) (*messages.MutationReply, error) {
|
func (s *cartActorGRPCServer) applyMutation(cartID string, mutation interface{}) *messages.CartMutationReply {
|
||||||
if envelope.GetCartId() == "" {
|
grain, err := s.pool.Apply(ToCartId(cartID), mutation)
|
||||||
return &messages.MutationReply{
|
|
||||||
StatusCode: 400,
|
|
||||||
Result: &messages.MutationReply_Error{Error: "cart_id is required"},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
cartID := ToCartId(envelope.GetCartId())
|
|
||||||
|
|
||||||
var mutation interface{}
|
|
||||||
switch m := envelope.Mutation.(type) {
|
|
||||||
case *messages.MutationEnvelope_AddRequest:
|
|
||||||
mutation = m.AddRequest
|
|
||||||
case *messages.MutationEnvelope_AddItem:
|
|
||||||
mutation = m.AddItem
|
|
||||||
case *messages.MutationEnvelope_RemoveItem:
|
|
||||||
mutation = m.RemoveItem
|
|
||||||
case *messages.MutationEnvelope_RemoveDelivery:
|
|
||||||
mutation = m.RemoveDelivery
|
|
||||||
case *messages.MutationEnvelope_ChangeQuantity:
|
|
||||||
mutation = m.ChangeQuantity
|
|
||||||
case *messages.MutationEnvelope_SetDelivery:
|
|
||||||
mutation = m.SetDelivery
|
|
||||||
case *messages.MutationEnvelope_SetPickupPoint:
|
|
||||||
mutation = m.SetPickupPoint
|
|
||||||
case *messages.MutationEnvelope_CreateCheckoutOrder:
|
|
||||||
mutation = m.CreateCheckoutOrder
|
|
||||||
case *messages.MutationEnvelope_SetCartItems:
|
|
||||||
mutation = m.SetCartItems
|
|
||||||
case *messages.MutationEnvelope_OrderCompleted:
|
|
||||||
mutation = m.OrderCompleted
|
|
||||||
default:
|
|
||||||
return &messages.MutationReply{
|
|
||||||
StatusCode: 400,
|
|
||||||
Result: &messages.MutationReply_Error{Error: fmt.Sprintf("unsupported mutation type: %T", m)},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delegate the mutation to the grain pool.
|
|
||||||
// The pool is responsible for routing it to the correct grain (local or remote).
|
|
||||||
grain, err := s.pool.Process(cartID, mutation)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &messages.MutationReply{
|
return &messages.CartMutationReply{
|
||||||
StatusCode: 500,
|
StatusCode: 500,
|
||||||
Result: &messages.MutationReply_Error{Error: err.Error()},
|
Result: &messages.CartMutationReply_Error{Error: err.Error()},
|
||||||
}, nil
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cartState := ToCartState(grain)
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 200,
|
||||||
|
Result: &messages.CartMutationReply_State{State: cartState},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map the internal grain state to the protobuf representation.
|
func (s *cartActorGRPCServer) AddRequest(ctx context.Context, req *messages.AddRequestRequest) (*messages.CartMutationReply, error) {
|
||||||
cartState := ToCartState(grain)
|
if req.GetCartId() == "" {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
return &messages.MutationReply{
|
StatusCode: 400,
|
||||||
StatusCode: 200,
|
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||||
Result: &messages.MutationReply_State{State: cartState},
|
ServerTimestamp: time.Now().Unix(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *cartActorGRPCServer) AddItem(ctx context.Context, req *messages.AddItemRequest) (*messages.CartMutationReply, error) {
|
||||||
|
if req.GetCartId() == "" {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 400,
|
||||||
|
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *cartActorGRPCServer) RemoveItem(ctx context.Context, req *messages.RemoveItemRequest) (*messages.CartMutationReply, error) {
|
||||||
|
if req.GetCartId() == "" {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 400,
|
||||||
|
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *cartActorGRPCServer) RemoveDelivery(ctx context.Context, req *messages.RemoveDeliveryRequest) (*messages.CartMutationReply, error) {
|
||||||
|
if req.GetCartId() == "" {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 400,
|
||||||
|
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *cartActorGRPCServer) ChangeQuantity(ctx context.Context, req *messages.ChangeQuantityRequest) (*messages.CartMutationReply, error) {
|
||||||
|
if req.GetCartId() == "" {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 400,
|
||||||
|
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *cartActorGRPCServer) SetDelivery(ctx context.Context, req *messages.SetDeliveryRequest) (*messages.CartMutationReply, error) {
|
||||||
|
if req.GetCartId() == "" {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 400,
|
||||||
|
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *cartActorGRPCServer) SetPickupPoint(ctx context.Context, req *messages.SetPickupPointRequest) (*messages.CartMutationReply, error) {
|
||||||
|
if req.GetCartId() == "" {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 400,
|
||||||
|
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Checkout RPC removed. Checkout is handled at the HTTP layer (PoolServer.HandleCheckout).
|
||||||
|
*/
|
||||||
|
|
||||||
|
func (s *cartActorGRPCServer) SetCartItems(ctx context.Context, req *messages.SetCartItemsRequest) (*messages.CartMutationReply, error) {
|
||||||
|
if req.GetCartId() == "" {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 400,
|
||||||
|
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *cartActorGRPCServer) OrderCompleted(ctx context.Context, req *messages.OrderCompletedRequest) (*messages.CartMutationReply, error) {
|
||||||
|
if req.GetCartId() == "" {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 400,
|
||||||
|
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetState retrieves the current state of a cart grain.
|
// GetState retrieves the current state of a cart grain.
|
||||||
func (s *cartActorGRPCServer) GetState(ctx context.Context, req *messages.StateRequest) (*messages.StateReply, error) {
|
func (s *cartActorGRPCServer) GetState(ctx context.Context, req *messages.StateRequest) (*messages.StateReply, error) {
|
||||||
|
|||||||
264
main.go
264
main.go
@@ -1,15 +1,21 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/http/pprof"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"k8s.io/client-go/kubernetes"
|
"k8s.io/client-go/kubernetes"
|
||||||
"k8s.io/client-go/rest"
|
"k8s.io/client-go/rest"
|
||||||
)
|
)
|
||||||
@@ -76,13 +82,60 @@ func (a *App) Save() error {
|
|||||||
return a.storage.saveState()
|
return a.storage.saveState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) HandleSave(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := a.Save()
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var podIp = os.Getenv("POD_IP")
|
var podIp = os.Getenv("POD_IP")
|
||||||
var name = os.Getenv("POD_NAME")
|
var name = os.Getenv("POD_NAME")
|
||||||
var amqpUrl = os.Getenv("AMQP_URL")
|
var amqpUrl = os.Getenv("AMQP_URL")
|
||||||
var KlarnaInstance = NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
|
var KlarnaInstance = NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_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>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
%s
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
|
||||||
|
func getCountryFromHost(host string) string {
|
||||||
|
if strings.Contains(strings.ToLower(host), "-no") {
|
||||||
|
return "no"
|
||||||
|
}
|
||||||
|
return "se"
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCheckoutOrder(host string, cartId CartId) *messages.CreateCheckoutOrder {
|
||||||
|
baseUrl := fmt.Sprintf("https://%s", host)
|
||||||
|
cartBaseUrl := os.Getenv("CART_BASE_URL")
|
||||||
|
if cartBaseUrl == "" {
|
||||||
|
cartBaseUrl = "https://cart.tornberg.me"
|
||||||
|
}
|
||||||
|
country := getCountryFromHost(host)
|
||||||
|
|
||||||
|
return &messages.CreateCheckoutOrder{
|
||||||
|
Terms: fmt.Sprintf("%s/terms", baseUrl),
|
||||||
|
Checkout: fmt.Sprintf("%s/checkout?order_id={checkout.order.id}", baseUrl),
|
||||||
|
Confirmation: fmt.Sprintf("%s/confirmation/{checkout.order.id}", baseUrl),
|
||||||
|
Validation: fmt.Sprintf("%s/validation", cartBaseUrl),
|
||||||
|
Push: fmt.Sprintf("%s/push?order_id={checkout.order.id}", cartBaseUrl),
|
||||||
|
Country: country,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func GetDiscovery() Discovery {
|
func GetDiscovery() Discovery {
|
||||||
if podIp == "" {
|
if podIp == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -100,8 +153,6 @@ func GetDiscovery() Discovery {
|
|||||||
return NewK8sDiscovery(client)
|
return NewK8sDiscovery(client)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
||||||
storage, err := NewDiskStorage(fmt.Sprintf("data/%s_state.gob", name))
|
storage, err := NewDiskStorage(fmt.Sprintf("data/%s_state.gob", name))
|
||||||
@@ -134,7 +185,185 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
orderHandler := &AmqpOrderHandler{
|
||||||
|
Url: amqpUrl,
|
||||||
|
}
|
||||||
|
|
||||||
|
syncedServer := NewPoolServer(syncedPool, fmt.Sprintf("%s, %s", name, podIp))
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle("/cart/", http.StripPrefix("/cart", syncedServer.Serve()))
|
||||||
|
// only for local
|
||||||
|
// mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// syncedPool.AddRemote(r.PathValue("host"))
|
||||||
|
// })
|
||||||
|
// mux.HandleFunc("GET /save", app.HandleSave)
|
||||||
|
//mux.HandleFunc("/", app.RewritePath)
|
||||||
|
mux.HandleFunc("/debug/pprof/", pprof.Index)
|
||||||
|
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||||||
|
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
||||||
|
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
||||||
|
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
||||||
|
mux.Handle("/metrics", promhttp.Handler())
|
||||||
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Grain pool health: simple capacity check (mirrors previous GrainHandler.IsHealthy)
|
||||||
|
app.pool.mu.RLock()
|
||||||
|
grainCount := len(app.pool.grains)
|
||||||
|
capacity := app.pool.PoolSize
|
||||||
|
app.pool.mu.RUnlock()
|
||||||
|
if grainCount >= capacity {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte("grain pool at capacity"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !syncedPool.IsHealthy() {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte("control plane not healthy"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/checkout", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
orderId := r.URL.Query().Get("order_id")
|
||||||
|
order := &CheckoutOrder{}
|
||||||
|
|
||||||
|
if orderId == "" {
|
||||||
|
cookie, err := r.Cookie("cartid")
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cookie.Value == "" {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte("no cart id to checkout is empty"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cartId := ToCartId(cookie.Value)
|
||||||
|
_, err = syncedServer.pool.Apply(cartId, getCheckoutOrder(r.Host, cartId))
|
||||||
|
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 {
|
||||||
|
prevOrder, err := KlarnaInstance.GetOrder(orderId)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
order = prevOrder
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(fmt.Sprintf(tpl, order.HTMLSnippet)))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/confirmation/{order_id}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
orderId := r.PathValue("order_id")
|
||||||
|
order, err := KlarnaInstance.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)
|
||||||
|
w.Write([]byte(fmt.Sprintf(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 := KlarnaInstance.GetOrder(orderId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error creating request: %v\n", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = confirmOrder(order, orderHandler)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error confirming order: %v\n", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = triggerOrderCompleted(err, syncedServer, order)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error processing cart message: %v\n", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = KlarnaInstance.AcknowledgeOrder(orderId)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error acknowledging order: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("1.0.0"))
|
||||||
|
})
|
||||||
|
|
||||||
sigs := make(chan os.Signal, 1)
|
sigs := make(chan os.Signal, 1)
|
||||||
done := make(chan bool, 1)
|
done := make(chan bool, 1)
|
||||||
@@ -148,7 +377,34 @@ func main() {
|
|||||||
done <- true
|
done <- true
|
||||||
}()
|
}()
|
||||||
|
|
||||||
log.Print("Server started at port 1337")
|
log.Print("Server started at port 8083")
|
||||||
|
go http.ListenAndServe(":8083", mux)
|
||||||
<-done
|
<-done
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func triggerOrderCompleted(err error, syncedServer *PoolServer, order *CheckoutOrder) error {
|
||||||
|
_, err = syncedServer.pool.Apply(ToCartId(order.MerchantReference1), &messages.OrderCreated{
|
||||||
|
OrderId: order.ID,
|
||||||
|
Status: order.Status,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
82
mutation_add_item.go
Normal file
82
mutation_add_item.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_add_item.go
|
||||||
|
//
|
||||||
|
// Registers the AddItem cart mutation in the generic mutation registry.
|
||||||
|
// This replaces the legacy switch-based logic previously found in CartGrain.Apply.
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// * Validates quantity > 0
|
||||||
|
// * If an item with same SKU exists -> increases quantity
|
||||||
|
// * Else creates a new CartItem with computed tax amounts
|
||||||
|
// * Totals recalculated automatically via WithTotals()
|
||||||
|
//
|
||||||
|
// NOTE: Any future field additions in messages.AddItem that affect pricing / tax
|
||||||
|
// must keep this handler in sync.
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterMutation[messages.AddItem](
|
||||||
|
"AddItem",
|
||||||
|
func(g *CartGrain, m *messages.AddItem) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("AddItem: nil payload")
|
||||||
|
}
|
||||||
|
if m.Quantity < 1 {
|
||||||
|
return fmt.Errorf("AddItem: invalid quantity %d", m.Quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast path: merge with existing item having same SKU
|
||||||
|
if existing, found := g.FindItemWithSku(m.Sku); found {
|
||||||
|
existing.Quantity += int(m.Quantity)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
|
||||||
|
g.lastItemId++
|
||||||
|
taxRate := 2500
|
||||||
|
if m.Tax > 0 {
|
||||||
|
taxRate = int(m.Tax)
|
||||||
|
}
|
||||||
|
taxAmountPerUnit := GetTaxAmount(m.Price, taxRate)
|
||||||
|
|
||||||
|
g.Items = append(g.Items, &CartItem{
|
||||||
|
Id: g.lastItemId,
|
||||||
|
ItemId: int(m.ItemId),
|
||||||
|
Quantity: int(m.Quantity),
|
||||||
|
Sku: m.Sku,
|
||||||
|
Name: m.Name,
|
||||||
|
Price: m.Price,
|
||||||
|
TotalPrice: m.Price * int64(m.Quantity),
|
||||||
|
TotalTax: int64(taxAmountPerUnit * int64(m.Quantity)),
|
||||||
|
Image: m.Image,
|
||||||
|
Stock: StockStatus(m.Stock),
|
||||||
|
Disclaimer: m.Disclaimer,
|
||||||
|
Brand: m.Brand,
|
||||||
|
Category: m.Category,
|
||||||
|
Category2: m.Category2,
|
||||||
|
Category3: m.Category3,
|
||||||
|
Category4: m.Category4,
|
||||||
|
Category5: m.Category5,
|
||||||
|
OrgPrice: m.OrgPrice,
|
||||||
|
ArticleType: m.ArticleType,
|
||||||
|
Outlet: m.Outlet,
|
||||||
|
SellerId: m.SellerId,
|
||||||
|
SellerName: m.SellerName,
|
||||||
|
Tax: int(taxAmountPerUnit),
|
||||||
|
TaxRate: taxRate,
|
||||||
|
StoreId: m.StoreId,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
WithTotals(), // Recalculate totals after successful mutation
|
||||||
|
)
|
||||||
|
}
|
||||||
61
mutation_add_request.go
Normal file
61
mutation_add_request.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_add_request.go
|
||||||
|
//
|
||||||
|
// Registers the AddRequest mutation. This mutation is a higher-level intent
|
||||||
|
// (add by SKU + quantity) which may translate into either:
|
||||||
|
// - Increasing quantity of an existing line (same SKU), OR
|
||||||
|
// - Creating a new item by performing a product lookup (via getItemData inside CartGrain.AddItem)
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// - Validates non-empty SKU and quantity > 0
|
||||||
|
// - If an item with the SKU already exists: increments its quantity
|
||||||
|
// - Else delegates to CartGrain.AddItem (which itself produces an AddItem mutation)
|
||||||
|
// - Totals recalculated automatically (WithTotals)
|
||||||
|
//
|
||||||
|
// NOTE:
|
||||||
|
// - This handler purposely avoids duplicating the detailed AddItem logic;
|
||||||
|
// it reuses CartGrain.AddItem which then flows through the AddItem mutation
|
||||||
|
// registry handler.
|
||||||
|
// - Double total recalculation can occur (AddItem has WithTotals too), but
|
||||||
|
// is acceptable for clarity. Optimize later if needed.
|
||||||
|
//
|
||||||
|
// Potential future improvements:
|
||||||
|
// - Stock validation before increasing quantity
|
||||||
|
// - Reservation logic or concurrency guards around stock updates
|
||||||
|
// - Coupon / pricing rules applied conditionally during add-by-sku
|
||||||
|
func init() {
|
||||||
|
RegisterMutation[messages.AddRequest](
|
||||||
|
"AddRequest",
|
||||||
|
func(g *CartGrain, m *messages.AddRequest) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("AddRequest: nil payload")
|
||||||
|
}
|
||||||
|
if m.Sku == "" {
|
||||||
|
return fmt.Errorf("AddRequest: sku is empty")
|
||||||
|
}
|
||||||
|
if m.Quantity < 1 {
|
||||||
|
return fmt.Errorf("AddRequest: invalid quantity %d", m.Quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing line: accumulate quantity only.
|
||||||
|
if existing, found := g.FindItemWithSku(m.Sku); found {
|
||||||
|
existing.Quantity += int(m.Quantity)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// New line: delegate to higher-level AddItem flow (product lookup).
|
||||||
|
// We intentionally ignore the returned *CartGrain; registry will
|
||||||
|
// do totals again after this handler returns (harmless).
|
||||||
|
_, err := g.AddItem(m.Sku, int(m.Quantity), m.Country, m.StoreId)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
WithTotals(),
|
||||||
|
)
|
||||||
|
}
|
||||||
58
mutation_change_quantity.go
Normal file
58
mutation_change_quantity.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_change_quantity.go
|
||||||
|
//
|
||||||
|
// Registers the ChangeQuantity mutation.
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// - Locates an item by its cart-local line item Id (not source item_id).
|
||||||
|
// - If requested quantity <= 0 the line is removed.
|
||||||
|
// - Otherwise the line's Quantity field is updated.
|
||||||
|
// - Totals are recalculated (WithTotals).
|
||||||
|
//
|
||||||
|
// Error handling:
|
||||||
|
// - Returns an error if the item Id is not found.
|
||||||
|
// - Returns an error if payload is nil (defensive).
|
||||||
|
//
|
||||||
|
// Concurrency:
|
||||||
|
// - Uses the grain's RW-safe mutation pattern: we mutate in place under
|
||||||
|
// the grain's implicit expectation that higher layers control access.
|
||||||
|
// (If strict locking is required around every mutation, wrap logic in
|
||||||
|
// an explicit g.mu.Lock()/Unlock(), but current model mirrors prior code.)
|
||||||
|
func init() {
|
||||||
|
RegisterMutation[messages.ChangeQuantity](
|
||||||
|
"ChangeQuantity",
|
||||||
|
func(g *CartGrain, m *messages.ChangeQuantity) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("ChangeQuantity: nil payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
foundIndex := -1
|
||||||
|
for i, it := range g.Items {
|
||||||
|
if it.Id == int(m.Id) {
|
||||||
|
foundIndex = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if foundIndex == -1 {
|
||||||
|
return fmt.Errorf("ChangeQuantity: item id %d not found", m.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.Quantity <= 0 {
|
||||||
|
// Remove the item
|
||||||
|
g.Items = append(g.Items[:foundIndex], g.Items[foundIndex+1:]...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Items[foundIndex].Quantity = int(m.Quantity)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
WithTotals(),
|
||||||
|
)
|
||||||
|
}
|
||||||
49
mutation_initialize_checkout.go
Normal file
49
mutation_initialize_checkout.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_initialize_checkout.go
|
||||||
|
//
|
||||||
|
// Registers the InitializeCheckout mutation.
|
||||||
|
// This mutation is invoked AFTER an external Klarna checkout session
|
||||||
|
// has been successfully created or updated. It persists the Klarna
|
||||||
|
// order reference / status and marks the cart as having a payment in progress.
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// - Sets OrderReference to the Klarna order ID (overwriting if already set).
|
||||||
|
// - Sets PaymentStatus to the current Klarna status.
|
||||||
|
// - Sets / updates PaymentInProgress flag.
|
||||||
|
// - Does NOT alter pricing or line items (so no totals recalculation).
|
||||||
|
//
|
||||||
|
// Validation:
|
||||||
|
// - Returns an error if payload is nil.
|
||||||
|
// - Returns an error if orderId is empty (integrity guard).
|
||||||
|
//
|
||||||
|
// Concurrency:
|
||||||
|
// - Relies on upstream mutation serialization for a single grain. If
|
||||||
|
// parallel checkout attempts are possible, add higher-level guards
|
||||||
|
// (e.g. reject if PaymentInProgress already true unless reusing
|
||||||
|
// the same OrderReference).
|
||||||
|
func init() {
|
||||||
|
RegisterMutation[messages.InitializeCheckout](
|
||||||
|
"InitializeCheckout",
|
||||||
|
func(g *CartGrain, m *messages.InitializeCheckout) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("InitializeCheckout: nil payload")
|
||||||
|
}
|
||||||
|
if m.OrderId == "" {
|
||||||
|
return fmt.Errorf("InitializeCheckout: missing orderId")
|
||||||
|
}
|
||||||
|
|
||||||
|
g.OrderReference = m.OrderId
|
||||||
|
g.PaymentStatus = m.Status
|
||||||
|
g.PaymentInProgress = m.PaymentInProgress
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
// No WithTotals(): monetary aggregates are unaffected.
|
||||||
|
)
|
||||||
|
}
|
||||||
53
mutation_order_created.go
Normal file
53
mutation_order_created.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_order_created.go
|
||||||
|
//
|
||||||
|
// Registers the OrderCreated mutation.
|
||||||
|
//
|
||||||
|
// This mutation represents the completion (or state transition) of an order
|
||||||
|
// initiated earlier via InitializeCheckout / external Klarna processing.
|
||||||
|
// It finalizes (or updates) the cart's order metadata.
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// - Validates payload non-nil and OrderId not empty.
|
||||||
|
// - Sets (or overwrites) OrderReference with the provided OrderId.
|
||||||
|
// - Sets PaymentStatus from payload.Status.
|
||||||
|
// - Marks PaymentInProgress = false (checkout flow finished / acknowledged).
|
||||||
|
// - Does NOT adjust monetary totals (no WithTotals()).
|
||||||
|
//
|
||||||
|
// Notes / Future Extensions:
|
||||||
|
// - If multiple order completion events can arrive (e.g., retries / webhook
|
||||||
|
// replays), this handler is idempotent: it simply overwrites fields.
|
||||||
|
// - If you need to guard against conflicting order IDs, add a check:
|
||||||
|
// if g.OrderReference != "" && g.OrderReference != m.OrderId { ... }
|
||||||
|
// - Add audit logging or metrics here if required.
|
||||||
|
//
|
||||||
|
// Concurrency:
|
||||||
|
// - Relies on the higher-level guarantee that Apply() calls are serialized
|
||||||
|
// per grain. If out-of-order events are possible, embed versioning or
|
||||||
|
// timestamps in the mutation and compare before applying changes.
|
||||||
|
func init() {
|
||||||
|
RegisterMutation[messages.OrderCreated](
|
||||||
|
"OrderCreated",
|
||||||
|
func(g *CartGrain, m *messages.OrderCreated) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("OrderCreated: nil payload")
|
||||||
|
}
|
||||||
|
if m.OrderId == "" {
|
||||||
|
return fmt.Errorf("OrderCreated: missing orderId")
|
||||||
|
}
|
||||||
|
|
||||||
|
g.OrderReference = m.OrderId
|
||||||
|
g.PaymentStatus = m.Status
|
||||||
|
g.PaymentInProgress = false
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
// No WithTotals(): order completion does not modify pricing or taxes.
|
||||||
|
)
|
||||||
|
}
|
||||||
301
mutation_registry.go
Normal file
301
mutation_registry.go
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_registry.go
|
||||||
|
//
|
||||||
|
// Mutation Registry Infrastructure
|
||||||
|
// --------------------------------
|
||||||
|
// This file introduces a generic registry for cart mutations that:
|
||||||
|
//
|
||||||
|
// 1. Decouples mutation logic from the large type-switch inside CartGrain.Apply.
|
||||||
|
// 2. Enforces (at registration time) that every mutation handler has the correct
|
||||||
|
// signature: func(*CartGrain, *T) error
|
||||||
|
// 3. Optionally auto-updates cart totals after a mutation if flagged.
|
||||||
|
// 4. Provides a single authoritative list of registered mutations for
|
||||||
|
// introspection / coverage testing.
|
||||||
|
// 5. Allows incremental migration: you can first register new mutations here,
|
||||||
|
// and later prune the legacy switch cases.
|
||||||
|
//
|
||||||
|
// Usage Pattern
|
||||||
|
// -------------
|
||||||
|
// // Define your mutation proto message (e.g. messages.ApplyCoupon in messages.proto)
|
||||||
|
// // Regenerate protobufs.
|
||||||
|
//
|
||||||
|
// // In an init() (ideally in a small file like mutations_apply_coupon.go)
|
||||||
|
// func init() {
|
||||||
|
// RegisterMutation[*messages.ApplyCoupon](
|
||||||
|
// "ApplyCoupon",
|
||||||
|
// func(g *CartGrain, m *messages.ApplyCoupon) error {
|
||||||
|
// // domain logic ...
|
||||||
|
// discount := int64(5000)
|
||||||
|
// if g.TotalPrice < discount {
|
||||||
|
// discount = g.TotalPrice
|
||||||
|
// }
|
||||||
|
// g.TotalDiscount += discount
|
||||||
|
// g.TotalPrice -= discount
|
||||||
|
// return nil
|
||||||
|
// },
|
||||||
|
// WithTotals(), // we changed price-related fields; recalc totals
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // To invoke dynamically (alternative to the current switch):
|
||||||
|
// if updated, err := ApplyRegistered(grain, incomingMessage); err == nil {
|
||||||
|
// grain = updated
|
||||||
|
// } else if errors.Is(err, ErrMutationNotRegistered) {
|
||||||
|
// // fallback to legacy switch logic
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Migration Strategy
|
||||||
|
// ------------------
|
||||||
|
// 1. For each existing mutation handled in CartGrain.Apply, add a registry
|
||||||
|
// registration with equivalent logic.
|
||||||
|
// 2. Add a test that enumerates all *expected* mutation proto types and asserts
|
||||||
|
// they are present in RegisteredMutationTypes().
|
||||||
|
// 3. Once coverage is 100%, replace the switch in CartGrain.Apply with a call
|
||||||
|
// to ApplyRegistered (and optionally keep a minimal default to produce an
|
||||||
|
// "unsupported mutation" error).
|
||||||
|
//
|
||||||
|
// Thread Safety
|
||||||
|
// -------------
|
||||||
|
// Registration is typically done at init() time; a RWMutex provides safety
|
||||||
|
// should late dynamic registration ever be introduced.
|
||||||
|
//
|
||||||
|
// Auto Totals
|
||||||
|
// -----------
|
||||||
|
// Many mutations require recomputing totals. To avoid forgetting this, pass
|
||||||
|
// WithTotals() when registering. This will invoke grain.UpdateTotals() after
|
||||||
|
// the handler returns successfully.
|
||||||
|
//
|
||||||
|
// Error Semantics
|
||||||
|
// ---------------
|
||||||
|
// - If a handler returns an error, totals are NOT recalculated (even if
|
||||||
|
// WithTotals() was specified).
|
||||||
|
// - ApplyRegistered returns (nil, ErrMutationNotRegistered) if the message type
|
||||||
|
// is absent.
|
||||||
|
//
|
||||||
|
// Extensibility
|
||||||
|
// -------------
|
||||||
|
// It is straightforward to add options like audit hooks, metrics wrappers,
|
||||||
|
// or optimistic concurrency guards by extending MutationOption.
|
||||||
|
//
|
||||||
|
// NOTE: Generics require Go 1.18+. If constrained to earlier Go versions,
|
||||||
|
// replace the generic registration with a non-generic RegisterMutationType
|
||||||
|
// that accepts reflect.Type and an adapter function.
|
||||||
|
//
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
var (
|
||||||
|
mutationRegistryMu sync.RWMutex
|
||||||
|
mutationRegistry = make(map[reflect.Type]*registeredMutation)
|
||||||
|
|
||||||
|
// ErrMutationNotRegistered is returned when no handler exists for a given mutation type.
|
||||||
|
ErrMutationNotRegistered = fmt.Errorf("mutation not registered")
|
||||||
|
)
|
||||||
|
|
||||||
|
// MutationOption configures additional behavior for a registered mutation.
|
||||||
|
type MutationOption func(*mutationOptions)
|
||||||
|
|
||||||
|
// mutationOptions holds flags adjustable per registration.
|
||||||
|
type mutationOptions struct {
|
||||||
|
updateTotals bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTotals ensures CartGrain.UpdateTotals() is called after a successful handler.
|
||||||
|
func WithTotals() MutationOption {
|
||||||
|
return func(o *mutationOptions) {
|
||||||
|
o.updateTotals = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// registeredMutation stores metadata + the execution closure.
|
||||||
|
type registeredMutation struct {
|
||||||
|
name string
|
||||||
|
handler func(*CartGrain, interface{}) error
|
||||||
|
updateTotals bool
|
||||||
|
msgType reflect.Type
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterMutation registers a mutation handler for a specific message type T.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
//
|
||||||
|
// name - a human-readable identifier (used for diagnostics / coverage tests).
|
||||||
|
// handler - business logic operating on the cart grain & strongly typed message.
|
||||||
|
// options - optional behavior flags (e.g., WithTotals()).
|
||||||
|
//
|
||||||
|
// Panics if:
|
||||||
|
// - name is empty
|
||||||
|
// - handler is nil
|
||||||
|
// - duplicate registration for the same message type T
|
||||||
|
//
|
||||||
|
// Typical call is placed in an init() function.
|
||||||
|
func RegisterMutation[T any](name string, handler func(*CartGrain, *T) error, options ...MutationOption) {
|
||||||
|
if name == "" {
|
||||||
|
panic("RegisterMutation: name is required")
|
||||||
|
}
|
||||||
|
if handler == nil {
|
||||||
|
panic("RegisterMutation: handler is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive the reflect.Type for *T then its Elem (T) for mapping.
|
||||||
|
var zero *T
|
||||||
|
rtPtr := reflect.TypeOf(zero)
|
||||||
|
if rtPtr.Kind() != reflect.Ptr {
|
||||||
|
panic("RegisterMutation: expected pointer type for generic parameter")
|
||||||
|
}
|
||||||
|
rt := rtPtr.Elem()
|
||||||
|
|
||||||
|
opts := mutationOptions{}
|
||||||
|
for _, opt := range options {
|
||||||
|
opt(&opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapped := func(g *CartGrain, m interface{}) error {
|
||||||
|
typed, ok := m.(*T)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("mutation type mismatch: have %T want *%s", m, rt.Name())
|
||||||
|
}
|
||||||
|
return handler(g, typed)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutationRegistryMu.Lock()
|
||||||
|
defer mutationRegistryMu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := mutationRegistry[rt]; exists {
|
||||||
|
panic(fmt.Sprintf("RegisterMutation: duplicate registration for type %s", rt.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
mutationRegistry[rt] = ®isteredMutation{
|
||||||
|
name: name,
|
||||||
|
handler: wrapped,
|
||||||
|
updateTotals: opts.updateTotals,
|
||||||
|
msgType: rt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyRegistered attempts to apply a registered mutation.
|
||||||
|
// Returns updated grain if successful.
|
||||||
|
//
|
||||||
|
// If the mutation is not registered, returns (nil, ErrMutationNotRegistered).
|
||||||
|
func ApplyRegistered(grain *CartGrain, msg interface{}) (*CartGrain, error) {
|
||||||
|
if grain == nil {
|
||||||
|
return nil, fmt.Errorf("nil grain")
|
||||||
|
}
|
||||||
|
if msg == nil {
|
||||||
|
return nil, fmt.Errorf("nil mutation message")
|
||||||
|
}
|
||||||
|
|
||||||
|
rt := indirectType(reflect.TypeOf(msg))
|
||||||
|
mutationRegistryMu.RLock()
|
||||||
|
entry, ok := mutationRegistry[rt]
|
||||||
|
mutationRegistryMu.RUnlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrMutationNotRegistered
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := entry.handler(grain, msg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.updateTotals {
|
||||||
|
grain.UpdateTotals()
|
||||||
|
}
|
||||||
|
|
||||||
|
return grain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisteredMutations returns metadata for all registered mutations (snapshot).
|
||||||
|
func RegisteredMutations() []string {
|
||||||
|
mutationRegistryMu.RLock()
|
||||||
|
defer mutationRegistryMu.RUnlock()
|
||||||
|
out := make([]string, 0, len(mutationRegistry))
|
||||||
|
for _, entry := range mutationRegistry {
|
||||||
|
out = append(out, entry.name)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisteredMutationTypes returns the reflect.Type list of all registered messages.
|
||||||
|
// Useful for coverage tests ensuring expected set matches actual set.
|
||||||
|
func RegisteredMutationTypes() []reflect.Type {
|
||||||
|
mutationRegistryMu.RLock()
|
||||||
|
defer mutationRegistryMu.RUnlock()
|
||||||
|
out := make([]reflect.Type, 0, len(mutationRegistry))
|
||||||
|
for t := range mutationRegistry {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustAssertMutationCoverage can be called at startup to ensure every expected
|
||||||
|
// mutation type has been registered. It panics with a descriptive message if any
|
||||||
|
// are missing. Provide a slice of prototype pointers (e.g. []*messages.AddItem{nil} ...)
|
||||||
|
func MustAssertMutationCoverage(expected []interface{}) {
|
||||||
|
mutationRegistryMu.RLock()
|
||||||
|
defer mutationRegistryMu.RUnlock()
|
||||||
|
|
||||||
|
missing := make([]string, 0)
|
||||||
|
for _, ex := range expected {
|
||||||
|
if ex == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t := indirectType(reflect.TypeOf(ex))
|
||||||
|
if _, ok := mutationRegistry[t]; !ok {
|
||||||
|
missing = append(missing, t.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
panic(fmt.Sprintf("mutation registry missing handlers for: %v", missing))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// indirectType returns the element type if given a pointer; otherwise the type itself.
|
||||||
|
func indirectType(t reflect.Type) reflect.Type {
|
||||||
|
for t.Kind() == reflect.Ptr {
|
||||||
|
t = t.Elem()
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Integration Guide
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
1. Register all existing mutations:
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterMutation[*messages.AddItem]("AddItem",
|
||||||
|
func(g *CartGrain, m *messages.AddItem) error {
|
||||||
|
// (port logic from existing switch branch)
|
||||||
|
// ...
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
WithTotals(),
|
||||||
|
)
|
||||||
|
// ... repeat for others
|
||||||
|
}
|
||||||
|
|
||||||
|
2. In CartGrain.Apply (early in the method) add:
|
||||||
|
|
||||||
|
if updated, err := ApplyRegistered(c, content); err == nil {
|
||||||
|
return updated, nil
|
||||||
|
} else if err != ErrMutationNotRegistered {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// existing switch fallback below
|
||||||
|
|
||||||
|
3. Once all mutations are registered, remove the legacy switch cases
|
||||||
|
and leave a single ErrMutationNotRegistered path for unknown types.
|
||||||
|
|
||||||
|
4. Add a coverage test (see docs for example; removed from source for clarity).
|
||||||
|
5. (Optional) Add metrics / tracing wrappers for handlers.
|
||||||
|
|
||||||
|
*/
|
||||||
53
mutation_remove_delivery.go
Normal file
53
mutation_remove_delivery.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_remove_delivery.go
|
||||||
|
//
|
||||||
|
// Registers the RemoveDelivery mutation.
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// - Removes the delivery entry whose Id == payload.Id.
|
||||||
|
// - If not found, returns an error.
|
||||||
|
// - Cart totals are recalculated (WithTotals) after removal.
|
||||||
|
// - Items previously associated with that delivery simply become "without delivery";
|
||||||
|
// subsequent delivery mutations can reassign them.
|
||||||
|
//
|
||||||
|
// Differences vs legacy:
|
||||||
|
// - Legacy logic decremented TotalPrice explicitly before recalculating.
|
||||||
|
// Here we rely solely on UpdateTotals() to recompute from remaining
|
||||||
|
// deliveries and items (simpler / single source of truth).
|
||||||
|
//
|
||||||
|
// Future considerations:
|
||||||
|
// - If delivery pricing logic changes (e.g., dynamic taxes per delivery),
|
||||||
|
// UpdateTotals() may need enhancement to incorporate delivery tax properly.
|
||||||
|
func init() {
|
||||||
|
RegisterMutation[messages.RemoveDelivery](
|
||||||
|
"RemoveDelivery",
|
||||||
|
func(g *CartGrain, m *messages.RemoveDelivery) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("RemoveDelivery: nil payload")
|
||||||
|
}
|
||||||
|
targetID := int(m.Id)
|
||||||
|
index := -1
|
||||||
|
for i, d := range g.Deliveries {
|
||||||
|
if d.Id == targetID {
|
||||||
|
index = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if index == -1 {
|
||||||
|
return fmt.Errorf("RemoveDelivery: delivery id %d not found", m.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove delivery (order not preserved beyond necessity)
|
||||||
|
g.Deliveries = append(g.Deliveries[:index], g.Deliveries[index+1:]...)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
WithTotals(),
|
||||||
|
)
|
||||||
|
}
|
||||||
49
mutation_remove_item.go
Normal file
49
mutation_remove_item.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_remove_item.go
|
||||||
|
//
|
||||||
|
// Registers the RemoveItem mutation.
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// - Removes the cart line whose local cart line Id == payload.Id
|
||||||
|
// - If no such line exists returns an error
|
||||||
|
// - Recalculates cart totals (WithTotals)
|
||||||
|
//
|
||||||
|
// Notes:
|
||||||
|
// - This removes only the line item; any deliveries referencing the removed
|
||||||
|
// item are NOT automatically adjusted (mirrors prior logic). If future
|
||||||
|
// semantics require pruning delivery.item_ids you can extend this handler.
|
||||||
|
// - If multiple lines somehow shared the same Id (should not happen), only
|
||||||
|
// the first match would be removed—data integrity relies on unique line Ids.
|
||||||
|
func init() {
|
||||||
|
RegisterMutation[messages.RemoveItem](
|
||||||
|
"RemoveItem",
|
||||||
|
func(g *CartGrain, m *messages.RemoveItem) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("RemoveItem: nil payload")
|
||||||
|
}
|
||||||
|
targetID := int(m.Id)
|
||||||
|
|
||||||
|
index := -1
|
||||||
|
for i, it := range g.Items {
|
||||||
|
if it.Id == targetID {
|
||||||
|
index = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if index == -1 {
|
||||||
|
return fmt.Errorf("RemoveItem: item id %d not found", m.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Items = append(g.Items[:index], g.Items[index+1:]...)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
WithTotals(),
|
||||||
|
)
|
||||||
|
}
|
||||||
57
mutation_set_cart_items.go
Normal file
57
mutation_set_cart_items.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_set_cart_items.go
|
||||||
|
//
|
||||||
|
// Registers the SetCartRequest mutation. This mutation replaces the entire list
|
||||||
|
// of cart items with the provided list (each entry is an AddRequest).
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// - Clears existing items (but leaves deliveries intact).
|
||||||
|
// - Iterates over each AddRequest and delegates to CartGrain.AddItem
|
||||||
|
// (which performs product lookup, creates AddItem mutation).
|
||||||
|
// - If any single addition fails, the mutation aborts with an error;
|
||||||
|
// items added prior to the failure remain (consistent with previous behavior).
|
||||||
|
// - Totals recalculated after completion via WithTotals().
|
||||||
|
//
|
||||||
|
// Notes:
|
||||||
|
// - Potential optimization: batch product lookups; currently sequential.
|
||||||
|
// - Consider adding rollback semantics if atomic replacement is desired.
|
||||||
|
// - Deliveries might reference item IDs that are now invalid—original logic
|
||||||
|
// also left deliveries untouched. If that becomes an issue, add a cleanup
|
||||||
|
// pass to remove deliveries whose item IDs no longer exist.
|
||||||
|
func init() {
|
||||||
|
RegisterMutation[messages.SetCartRequest](
|
||||||
|
"SetCartRequest",
|
||||||
|
func(g *CartGrain, m *messages.SetCartRequest) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("SetCartRequest: nil payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear current items (keep deliveries)
|
||||||
|
g.mu.Lock()
|
||||||
|
g.Items = make([]*CartItem, 0, len(m.Items))
|
||||||
|
g.mu.Unlock()
|
||||||
|
|
||||||
|
for _, it := range m.Items {
|
||||||
|
if it == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if it.Sku == "" || it.Quantity < 1 {
|
||||||
|
return fmt.Errorf("SetCartRequest: invalid item (sku='%s' qty=%d)", it.Sku, it.Quantity)
|
||||||
|
}
|
||||||
|
_, err := g.AddItem(it.Sku, int(it.Quantity), it.Country, it.StoreId)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("SetCartRequest: add sku '%s' failed: %w", it.Sku, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
WithTotals(),
|
||||||
|
)
|
||||||
|
}
|
||||||
101
mutation_set_delivery.go
Normal file
101
mutation_set_delivery.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_set_delivery.go
|
||||||
|
//
|
||||||
|
// Registers the SetDelivery mutation.
|
||||||
|
//
|
||||||
|
// Semantics (mirrors legacy switch logic):
|
||||||
|
// - If the payload specifies an explicit list of item IDs (payload.Items):
|
||||||
|
// - Each referenced cart line must exist.
|
||||||
|
// - None of the referenced items may already belong to a delivery.
|
||||||
|
// - Only those items are associated with the new delivery.
|
||||||
|
// - If payload.Items is empty:
|
||||||
|
// - All items currently without any delivery are associated with the new delivery.
|
||||||
|
// - A new delivery line is created with:
|
||||||
|
// - Auto-incremented delivery ID (cart-local)
|
||||||
|
// - Provider from payload
|
||||||
|
// - Fixed price (currently hard-coded: 4900 minor units) – adjust as needed
|
||||||
|
// - Optional PickupPoint copied from payload
|
||||||
|
// - Cart totals are recalculated (WithTotals)
|
||||||
|
//
|
||||||
|
// Error cases:
|
||||||
|
// - Referenced item does not exist
|
||||||
|
// - Referenced item already has a delivery
|
||||||
|
// - No items qualify (resulting association set empty) -> returns error (prevents creating empty delivery)
|
||||||
|
//
|
||||||
|
// Concurrency:
|
||||||
|
// - Uses g.mu to protect lastDeliveryId increment and append to Deliveries slice.
|
||||||
|
// Item scans are read-only and performed outside the lock for simplicity;
|
||||||
|
// if stricter guarantees are needed, widen the lock section.
|
||||||
|
//
|
||||||
|
// Future extension points:
|
||||||
|
// - Variable delivery pricing (based on weight, distance, provider, etc.)
|
||||||
|
// - Validation of provider codes
|
||||||
|
// - Multi-currency delivery pricing
|
||||||
|
func init() {
|
||||||
|
RegisterMutation[messages.SetDelivery](
|
||||||
|
"SetDelivery",
|
||||||
|
func(g *CartGrain, m *messages.SetDelivery) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("SetDelivery: nil payload")
|
||||||
|
}
|
||||||
|
if m.Provider == "" {
|
||||||
|
return fmt.Errorf("SetDelivery: provider is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
withDelivery := g.ItemsWithDelivery()
|
||||||
|
targetItems := make([]int, 0)
|
||||||
|
|
||||||
|
if len(m.Items) == 0 {
|
||||||
|
// Use every item currently without a delivery
|
||||||
|
targetItems = append(targetItems, g.ItemsWithoutDelivery()...)
|
||||||
|
} else {
|
||||||
|
// Validate explicit list
|
||||||
|
for _, id64 := range m.Items {
|
||||||
|
id := int(id64)
|
||||||
|
found := false
|
||||||
|
for _, it := range g.Items {
|
||||||
|
if it.Id == id {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("SetDelivery: item id %d not found", id)
|
||||||
|
}
|
||||||
|
if slices.Contains(withDelivery, id) {
|
||||||
|
return fmt.Errorf("SetDelivery: item id %d already has a delivery", id)
|
||||||
|
}
|
||||||
|
targetItems = append(targetItems, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(targetItems) == 0 {
|
||||||
|
return fmt.Errorf("SetDelivery: no eligible items to attach")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append new delivery
|
||||||
|
g.mu.Lock()
|
||||||
|
g.lastDeliveryId++
|
||||||
|
newId := g.lastDeliveryId
|
||||||
|
g.Deliveries = append(g.Deliveries, &CartDelivery{
|
||||||
|
Id: newId,
|
||||||
|
Provider: m.Provider,
|
||||||
|
PickupPoint: m.PickupPoint,
|
||||||
|
Price: 4900, // TODO: externalize pricing
|
||||||
|
Items: targetItems,
|
||||||
|
})
|
||||||
|
g.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
WithTotals(),
|
||||||
|
)
|
||||||
|
}
|
||||||
56
mutation_set_pickup_point.go
Normal file
56
mutation_set_pickup_point.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_set_pickup_point.go
|
||||||
|
//
|
||||||
|
// Registers the SetPickupPoint mutation using the generic mutation registry.
|
||||||
|
//
|
||||||
|
// Semantics (mirrors original switch-based implementation):
|
||||||
|
// - Locate the delivery with Id == payload.DeliveryId
|
||||||
|
// - Set (or overwrite) its PickupPoint with the provided data
|
||||||
|
// - Does NOT alter pricing or taxes (so no totals recalculation required)
|
||||||
|
//
|
||||||
|
// Validation / Error Handling:
|
||||||
|
// - If payload is nil -> error
|
||||||
|
// - If DeliveryId not found -> error
|
||||||
|
//
|
||||||
|
// Concurrency:
|
||||||
|
// - Relies on the existing expectation that higher-level mutation routing
|
||||||
|
// serializes Apply() calls per grain; if stricter guarantees are needed,
|
||||||
|
// a delivery-level lock could be introduced later.
|
||||||
|
//
|
||||||
|
// Future Extensions:
|
||||||
|
// - Validate pickup point fields (country code, zip format, etc.)
|
||||||
|
// - Track history / audit of pickup point changes
|
||||||
|
// - Trigger delivery price adjustments (which would then require WithTotals()).
|
||||||
|
func init() {
|
||||||
|
RegisterMutation[messages.SetPickupPoint](
|
||||||
|
"SetPickupPoint",
|
||||||
|
func(g *CartGrain, m *messages.SetPickupPoint) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("SetPickupPoint: nil payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, d := range g.Deliveries {
|
||||||
|
if d.Id == int(m.DeliveryId) {
|
||||||
|
d.PickupPoint = &messages.PickupPoint{
|
||||||
|
Id: m.Id,
|
||||||
|
Name: m.Name,
|
||||||
|
Address: m.Address,
|
||||||
|
City: m.City,
|
||||||
|
Zip: m.Zip,
|
||||||
|
Country: m.Country,
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("SetPickupPoint: delivery id %d not found", m.DeliveryId)
|
||||||
|
},
|
||||||
|
// No WithTotals(): pickup point does not change pricing / tax.
|
||||||
|
)
|
||||||
|
}
|
||||||
351
pool-server.go
Normal file
351
pool-server.go
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PoolServer struct {
|
||||||
|
pod_name string
|
||||||
|
pool GrainPool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPoolServer(pool GrainPool, pod_name string) *PoolServer {
|
||||||
|
return &PoolServer{
|
||||||
|
pod_name: pod_name,
|
||||||
|
pool: pool,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) process(id CartId, mutation interface{}) (*messages.CartState, error) {
|
||||||
|
grain, err := s.pool.Apply(id, mutation)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ToCartState(grain), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) HandleGet(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
grain, err := s.pool.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.WriteResult(w, ToCartState(grain))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) HandleAddSku(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
sku := r.PathValue("sku")
|
||||||
|
data, err := s.process(id, &messages.AddRequest{Sku: sku, Quantity: 1})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.WriteResult(w, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ErrorHandler(fn func(w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := fn(w, r)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Server error, not remote error: %v\n", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) WriteResult(w http.ResponseWriter, result *messages.CartState) error {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("X-Pod-Name", s.pod_name)
|
||||||
|
if result == nil {
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
enc := json.NewEncoder(w)
|
||||||
|
err := enc.Encode(result)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) HandleDeleteItem(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
|
||||||
|
itemIdString := r.PathValue("itemId")
|
||||||
|
itemId, err := strconv.Atoi(itemIdString)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := s.process(id, &messages.RemoveItem{Id: int64(itemId)})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.WriteResult(w, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetDelivery struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Items []int64 `json:"items"`
|
||||||
|
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) HandleSetDelivery(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
|
||||||
|
delivery := SetDelivery{}
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&delivery)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := s.process(id, &messages.SetDelivery{
|
||||||
|
Provider: delivery.Provider,
|
||||||
|
Items: delivery.Items,
|
||||||
|
PickupPoint: delivery.PickupPoint,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.WriteResult(w, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) HandleSetPickupPoint(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
|
||||||
|
deliveryIdString := r.PathValue("deliveryId")
|
||||||
|
deliveryId, err := strconv.Atoi(deliveryIdString)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pickupPoint := messages.PickupPoint{}
|
||||||
|
err = json.NewDecoder(r.Body).Decode(&pickupPoint)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reply, err := s.process(id, &messages.SetPickupPoint{
|
||||||
|
DeliveryId: int64(deliveryId),
|
||||||
|
Id: pickupPoint.Id,
|
||||||
|
Name: pickupPoint.Name,
|
||||||
|
Address: pickupPoint.Address,
|
||||||
|
City: pickupPoint.City,
|
||||||
|
Zip: pickupPoint.Zip,
|
||||||
|
Country: pickupPoint.Country,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.WriteResult(w, reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) HandleRemoveDelivery(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
|
||||||
|
deliveryIdString := r.PathValue("deliveryId")
|
||||||
|
deliveryId, err := strconv.Atoi(deliveryIdString)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reply, err := s.process(id, &messages.RemoveDelivery{Id: int64(deliveryId)})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.WriteResult(w, reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) HandleQuantityChange(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
changeQuantity := messages.ChangeQuantity{}
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&changeQuantity)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reply, err := s.process(id, &changeQuantity)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.WriteResult(w, reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) HandleSetCartItems(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
setCartItems := messages.SetCartRequest{}
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&setCartItems)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reply, err := s.process(id, &setCartItems)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.WriteResult(w, reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) HandleAddRequest(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
addRequest := messages.AddRequest{}
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&addRequest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reply, err := s.process(id, &addRequest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.WriteResult(w, reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) HandleConfirmation(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
orderId := r.PathValue("orderId")
|
||||||
|
if orderId == "" {
|
||||||
|
return fmt.Errorf("orderId is empty")
|
||||||
|
}
|
||||||
|
order, err := KlarnaInstance.GetOrder(orderId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("X-Pod-Name", s.pod_name)
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return json.NewEncoder(w).Encode(order)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) HandleCheckout(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
// Build checkout meta (URLs derived from host)
|
||||||
|
meta := &CheckoutMeta{
|
||||||
|
Terms: fmt.Sprintf("https://%s/terms", r.Host),
|
||||||
|
Checkout: fmt.Sprintf("https://%s/checkout?order_id={checkout.order.id}", r.Host),
|
||||||
|
Confirmation: fmt.Sprintf("https://%s/confirmation/{checkout.order.id}", r.Host),
|
||||||
|
Validation: fmt.Sprintf("https://%s/validate", r.Host),
|
||||||
|
Push: fmt.Sprintf("https://%s/push?order_id={checkout.order.id}", r.Host),
|
||||||
|
Country: getCountryFromHost(r.Host),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current grain state (may be local or remote)
|
||||||
|
grain, err := s.pool.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build pure checkout payload
|
||||||
|
payload, _, err := BuildCheckoutOrderPayload(grain, meta)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call Klarna (create or update)
|
||||||
|
var klarnaOrder *CheckoutOrder
|
||||||
|
if grain.OrderReference != "" {
|
||||||
|
klarnaOrder, err = KlarnaInstance.UpdateOrder(grain.OrderReference, bytes.NewReader(payload))
|
||||||
|
} else {
|
||||||
|
klarnaOrder, err = KlarnaInstance.CreateOrder(bytes.NewReader(payload))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist initialization state via mutation (best-effort)
|
||||||
|
if _, applyErr := s.pool.Apply(id, &messages.InitializeCheckout{
|
||||||
|
OrderId: klarnaOrder.ID,
|
||||||
|
Status: klarnaOrder.Status,
|
||||||
|
PaymentInProgress: true,
|
||||||
|
}); applyErr != nil {
|
||||||
|
log.Printf("InitializeCheckout apply error: %v", applyErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
return json.NewEncoder(w).Encode(klarnaOrder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCartId() CartId {
|
||||||
|
id := time.Now().UnixNano() + rand.Int63()
|
||||||
|
|
||||||
|
return ToCartId(fmt.Sprintf("%d", id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func CookieCartIdHandler(fn func(w http.ResponseWriter, r *http.Request, cartId CartId) error) func(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
var cartId CartId
|
||||||
|
cartIdCookie := r.CookiesNamed("cartid")
|
||||||
|
if cartIdCookie == nil || len(cartIdCookie) == 0 {
|
||||||
|
cartId = NewCartId()
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "cartid",
|
||||||
|
Value: cartId.String(),
|
||||||
|
Secure: r.TLS != nil,
|
||||||
|
HttpOnly: true,
|
||||||
|
Path: "/",
|
||||||
|
Expires: time.Now().AddDate(0, 0, 14),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
cartId = ToCartId(cartIdCookie[0].Value)
|
||||||
|
}
|
||||||
|
return fn(w, r, cartId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, cartId CartId) error {
|
||||||
|
cartId = NewCartId()
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "cartid",
|
||||||
|
Value: cartId.String(),
|
||||||
|
Path: "/",
|
||||||
|
Secure: r.TLS != nil,
|
||||||
|
HttpOnly: true,
|
||||||
|
Expires: time.Unix(0, 0),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CartIdHandler(fn func(w http.ResponseWriter, r *http.Request, cartId CartId) error) func(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
cartId := ToCartId(r.PathValue("id"))
|
||||||
|
return fn(w, r, cartId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) Serve() *http.ServeMux {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
//mux.HandleFunc("/", s.RewritePath)
|
||||||
|
mux.HandleFunc("OPTIONS /", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /", ErrorHandler(CookieCartIdHandler(s.HandleGet)))
|
||||||
|
mux.HandleFunc("GET /add/{sku}", ErrorHandler(CookieCartIdHandler(s.HandleAddSku)))
|
||||||
|
mux.HandleFunc("POST /", ErrorHandler(CookieCartIdHandler(s.HandleAddRequest)))
|
||||||
|
mux.HandleFunc("POST /set", ErrorHandler(CookieCartIdHandler(s.HandleSetCartItems)))
|
||||||
|
mux.HandleFunc("DELETE /{itemId}", ErrorHandler(CookieCartIdHandler(s.HandleDeleteItem)))
|
||||||
|
mux.HandleFunc("PUT /", ErrorHandler(CookieCartIdHandler(s.HandleQuantityChange)))
|
||||||
|
mux.HandleFunc("DELETE /", ErrorHandler(CookieCartIdHandler(s.RemoveCartCookie)))
|
||||||
|
mux.HandleFunc("POST /delivery", ErrorHandler(CookieCartIdHandler(s.HandleSetDelivery)))
|
||||||
|
mux.HandleFunc("DELETE /delivery/{deliveryId}", ErrorHandler(CookieCartIdHandler(s.HandleRemoveDelivery)))
|
||||||
|
mux.HandleFunc("PUT /delivery/{deliveryId}/pickupPoint", ErrorHandler(CookieCartIdHandler(s.HandleSetPickupPoint)))
|
||||||
|
mux.HandleFunc("GET /checkout", ErrorHandler(CookieCartIdHandler(s.HandleCheckout)))
|
||||||
|
mux.HandleFunc("GET /confirmation/{orderId}", ErrorHandler(CookieCartIdHandler(s.HandleConfirmation)))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /byid/{id}", ErrorHandler(CartIdHandler(s.HandleGet)))
|
||||||
|
mux.HandleFunc("GET /byid/{id}/add/{sku}", ErrorHandler(CartIdHandler(s.HandleAddSku)))
|
||||||
|
mux.HandleFunc("POST /byid/{id}", ErrorHandler(CartIdHandler(s.HandleAddRequest)))
|
||||||
|
mux.HandleFunc("DELETE /byid/{id}/{itemId}", ErrorHandler(CartIdHandler(s.HandleDeleteItem)))
|
||||||
|
mux.HandleFunc("PUT /byid/{id}", ErrorHandler(CartIdHandler(s.HandleQuantityChange)))
|
||||||
|
mux.HandleFunc("POST /byid/{id}/delivery", ErrorHandler(CartIdHandler(s.HandleSetDelivery)))
|
||||||
|
mux.HandleFunc("DELETE /byid/{id}/delivery/{deliveryId}", ErrorHandler(CartIdHandler(s.HandleRemoveDelivery)))
|
||||||
|
mux.HandleFunc("PUT /byid/{id}/delivery/{deliveryId}/pickupPoint", ErrorHandler(CartIdHandler(s.HandleSetPickupPoint)))
|
||||||
|
mux.HandleFunc("GET /byid/{id}/checkout", ErrorHandler(CartIdHandler(s.HandleCheckout)))
|
||||||
|
mux.HandleFunc("GET /byid/{id}/confirmation", ErrorHandler(CartIdHandler(s.HandleConfirmation)))
|
||||||
|
|
||||||
|
return mux
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -3,66 +3,114 @@ syntax = "proto3";
|
|||||||
package messages;
|
package messages;
|
||||||
|
|
||||||
option go_package = "git.tornberg.me/go-cart-actor/proto;messages";
|
option go_package = "git.tornberg.me/go-cart-actor/proto;messages";
|
||||||
|
|
||||||
import "messages.proto";
|
import "messages.proto";
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Cart Actor gRPC API (oneof Envelope Variant)
|
// Cart Actor gRPC API (Breaking v2 - Per-Mutation RPCs)
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// This version removes the legacy numeric MutationType enum + raw bytes payload
|
// This version removes the previous MutationEnvelope + Mutate RPC.
|
||||||
// approach and replaces it with a strongly typed oneof envelope. Each concrete
|
// Each mutation now has its own request wrapper and dedicated RPC method
|
||||||
// mutation proto is embedded directly, enabling:
|
// providing simpler, type-focused client stubs and enabling per-mutation
|
||||||
// * Type-safe routing server-side (simple type switch on the oneof).
|
// metrics, auth and rate limiting.
|
||||||
// * Direct persistence of MutationEnvelope messages (no custom binary header).
|
|
||||||
// * Elimination of the legacy message handler registry.
|
|
||||||
//
|
//
|
||||||
// NOTE: Regenerate Go code after editing:
|
// Regenerate Go code after editing:
|
||||||
// protoc --go_out=. --go_opt=paths=source_relative \
|
// protoc --go_out=. --go_opt=paths=source_relative \
|
||||||
// --go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
// --go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
||||||
// proto/cart_actor.proto proto/messages.proto
|
// proto/cart_actor.proto proto/messages.proto
|
||||||
|
//
|
||||||
|
// Backward compatibility: This is a breaking change (old clients must update).
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
// MutationEnvelope carries exactly one mutation plus metadata.
|
// Shared reply for all mutation RPCs.
|
||||||
// client_timestamp:
|
message CartMutationReply {
|
||||||
// - Optional Unix timestamp provided by the client.
|
int32 status_code = 1; // HTTP-like status (200 success, 4xx client, 5xx server)
|
||||||
// - If zero the server MAY overwrite with its local time.
|
oneof result {
|
||||||
message MutationEnvelope {
|
CartState state = 2; // Updated cart state on success
|
||||||
|
string error = 3; // Error message on failure
|
||||||
|
}
|
||||||
|
int64 server_timestamp = 4; // Server-assigned Unix timestamp (optional auditing)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch current cart state without mutation.
|
||||||
|
message StateRequest {
|
||||||
string cart_id = 1;
|
string cart_id = 1;
|
||||||
int64 client_timestamp = 2;
|
|
||||||
|
|
||||||
oneof mutation {
|
|
||||||
AddRequest add_request = 10;
|
|
||||||
AddItem add_item = 11;
|
|
||||||
RemoveItem remove_item = 12;
|
|
||||||
RemoveDelivery remove_delivery = 13;
|
|
||||||
ChangeQuantity change_quantity = 14;
|
|
||||||
SetDelivery set_delivery = 15;
|
|
||||||
SetPickupPoint set_pickup_point = 16;
|
|
||||||
CreateCheckoutOrder create_checkout_order = 17;
|
|
||||||
SetCartRequest set_cart_items = 18;
|
|
||||||
OrderCreated order_completed = 19;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MutationReply returns a legacy-style status code plus a JSON payload
|
message StateReply {
|
||||||
// holding either the updated cart state (on success) or an error string.
|
|
||||||
message MutationReply {
|
|
||||||
int32 status_code = 1;
|
int32 status_code = 1;
|
||||||
|
|
||||||
// Exactly one of state or error will be set.
|
|
||||||
oneof result {
|
oneof result {
|
||||||
CartState state = 2;
|
CartState state = 2;
|
||||||
string error = 3;
|
string error = 3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// StateRequest fetches current cart state without mutating.
|
// Per-mutation request wrappers. We wrap the existing inner mutation
|
||||||
message StateRequest {
|
// messages (defined in messages.proto) to add cart_id + optional metadata
|
||||||
|
// without altering the inner message definitions.
|
||||||
|
|
||||||
|
message AddRequestRequest {
|
||||||
string cart_id = 1;
|
string cart_id = 1;
|
||||||
|
int64 client_timestamp = 2;
|
||||||
|
AddRequest payload = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AddItemRequest {
|
||||||
|
string cart_id = 1;
|
||||||
|
int64 client_timestamp = 2;
|
||||||
|
AddItem payload = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RemoveItemRequest {
|
||||||
|
string cart_id = 1;
|
||||||
|
int64 client_timestamp = 2;
|
||||||
|
RemoveItem payload = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RemoveDeliveryRequest {
|
||||||
|
string cart_id = 1;
|
||||||
|
int64 client_timestamp = 2;
|
||||||
|
RemoveDelivery payload = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ChangeQuantityRequest {
|
||||||
|
string cart_id = 1;
|
||||||
|
int64 client_timestamp = 2;
|
||||||
|
ChangeQuantity payload = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SetDeliveryRequest {
|
||||||
|
string cart_id = 1;
|
||||||
|
int64 client_timestamp = 2;
|
||||||
|
SetDelivery payload = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SetPickupPointRequest {
|
||||||
|
string cart_id = 1;
|
||||||
|
int64 client_timestamp = 2;
|
||||||
|
SetPickupPoint payload = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateCheckoutOrderRequest {
|
||||||
|
string cart_id = 1;
|
||||||
|
int64 client_timestamp = 2;
|
||||||
|
CreateCheckoutOrder payload = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SetCartItemsRequest {
|
||||||
|
string cart_id = 1;
|
||||||
|
int64 client_timestamp = 2;
|
||||||
|
SetCartRequest payload = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OrderCompletedRequest {
|
||||||
|
string cart_id = 1;
|
||||||
|
int64 client_timestamp = 2;
|
||||||
|
OrderCreated payload = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// CartState represents the full cart snapshot returned by state/mutation replies.
|
// Cart state snapshot (unchanged from v1 except envelope removal context)
|
||||||
// Replaces the previous raw JSON payload.
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
message CartState {
|
message CartState {
|
||||||
string cart_id = 1;
|
string cart_id = 1;
|
||||||
@@ -76,7 +124,6 @@ message CartState {
|
|||||||
string payment_status = 9;
|
string payment_status = 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lightweight representation of an item in the cart
|
|
||||||
message CartItemState {
|
message CartItemState {
|
||||||
int64 id = 1;
|
int64 id = 1;
|
||||||
int64 source_item_id = 2;
|
int64 source_item_id = 2;
|
||||||
@@ -105,38 +152,37 @@ message CartItemState {
|
|||||||
int32 stock = 25;
|
int32 stock = 25;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delivery / shipping entry
|
|
||||||
message DeliveryState {
|
message DeliveryState {
|
||||||
int64 id = 1;
|
int64 id = 1;
|
||||||
string provider = 2;
|
string provider = 2;
|
||||||
int64 price = 3;
|
int64 price = 3;
|
||||||
repeated int64 item_ids = 4;
|
repeated int64 item_ids = 4;
|
||||||
PickupPoint pickup_point = 5;
|
PickupPoint pickup_point = 5; // Defined in messages.proto
|
||||||
}
|
}
|
||||||
|
|
||||||
// StateReply mirrors MutationReply for consistency.
|
// (CheckoutRequest / CheckoutReply removed - checkout handled at HTTP layer)
|
||||||
message StateReply {
|
|
||||||
int32 status_code = 1;
|
|
||||||
|
|
||||||
oneof result {
|
// -----------------------------------------------------------------------------
|
||||||
CartState state = 2;
|
// Service definition (per-mutation RPCs + checkout)
|
||||||
string error = 3;
|
// -----------------------------------------------------------------------------
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CartActor exposes mutation and state retrieval for remote grains.
|
|
||||||
service CartActor {
|
service CartActor {
|
||||||
// Mutate applies a single mutation to a cart, creating the cart lazily if needed.
|
rpc AddRequest(AddRequestRequest) returns (CartMutationReply);
|
||||||
rpc Mutate(MutationEnvelope) returns (MutationReply);
|
rpc AddItem(AddItemRequest) returns (CartMutationReply);
|
||||||
|
rpc RemoveItem(RemoveItemRequest) returns (CartMutationReply);
|
||||||
|
rpc RemoveDelivery(RemoveDeliveryRequest) returns (CartMutationReply);
|
||||||
|
rpc ChangeQuantity(ChangeQuantityRequest) returns (CartMutationReply);
|
||||||
|
rpc SetDelivery(SetDeliveryRequest) returns (CartMutationReply);
|
||||||
|
rpc SetPickupPoint(SetPickupPointRequest) returns (CartMutationReply);
|
||||||
|
// (Checkout RPC removed - handled externally)
|
||||||
|
rpc SetCartItems(SetCartItemsRequest) returns (CartMutationReply);
|
||||||
|
rpc OrderCompleted(OrderCompletedRequest) returns (CartMutationReply);
|
||||||
|
|
||||||
// GetState retrieves the cart's current state (JSON).
|
|
||||||
rpc GetState(StateRequest) returns (StateReply);
|
rpc GetState(StateRequest) returns (StateReply);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Future Enhancements:
|
// Future enhancements:
|
||||||
// * Replace JSON state payload with a strongly typed CartState proto.
|
// * BatchMutate RPC (repeated heterogeneous mutations)
|
||||||
// * Add streaming RPC (e.g., WatchState) for live updates.
|
// * Streaming state updates (WatchState)
|
||||||
// * Add batch mutations (repeated MutationEnvelope) if performance requires.
|
// * Versioning / optimistic concurrency control
|
||||||
// * Introduce optimistic concurrency via version fields if external writers appear.
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -19,7 +19,15 @@ import (
|
|||||||
const _ = grpc.SupportPackageIsVersion9
|
const _ = grpc.SupportPackageIsVersion9
|
||||||
|
|
||||||
const (
|
const (
|
||||||
CartActor_Mutate_FullMethodName = "/messages.CartActor/Mutate"
|
CartActor_AddRequest_FullMethodName = "/messages.CartActor/AddRequest"
|
||||||
|
CartActor_AddItem_FullMethodName = "/messages.CartActor/AddItem"
|
||||||
|
CartActor_RemoveItem_FullMethodName = "/messages.CartActor/RemoveItem"
|
||||||
|
CartActor_RemoveDelivery_FullMethodName = "/messages.CartActor/RemoveDelivery"
|
||||||
|
CartActor_ChangeQuantity_FullMethodName = "/messages.CartActor/ChangeQuantity"
|
||||||
|
CartActor_SetDelivery_FullMethodName = "/messages.CartActor/SetDelivery"
|
||||||
|
CartActor_SetPickupPoint_FullMethodName = "/messages.CartActor/SetPickupPoint"
|
||||||
|
CartActor_SetCartItems_FullMethodName = "/messages.CartActor/SetCartItems"
|
||||||
|
CartActor_OrderCompleted_FullMethodName = "/messages.CartActor/OrderCompleted"
|
||||||
CartActor_GetState_FullMethodName = "/messages.CartActor/GetState"
|
CartActor_GetState_FullMethodName = "/messages.CartActor/GetState"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,11 +35,20 @@ const (
|
|||||||
//
|
//
|
||||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||||
//
|
//
|
||||||
// CartActor exposes mutation and state retrieval for remote grains.
|
// -----------------------------------------------------------------------------
|
||||||
|
// Service definition (per-mutation RPCs + checkout)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
type CartActorClient interface {
|
type CartActorClient interface {
|
||||||
// Mutate applies a single mutation to a cart, creating the cart lazily if needed.
|
AddRequest(ctx context.Context, in *AddRequestRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
|
||||||
Mutate(ctx context.Context, in *MutationEnvelope, opts ...grpc.CallOption) (*MutationReply, error)
|
AddItem(ctx context.Context, in *AddItemRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
|
||||||
// GetState retrieves the cart's current state (JSON).
|
RemoveItem(ctx context.Context, in *RemoveItemRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
|
||||||
|
RemoveDelivery(ctx context.Context, in *RemoveDeliveryRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
|
||||||
|
ChangeQuantity(ctx context.Context, in *ChangeQuantityRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
|
||||||
|
SetDelivery(ctx context.Context, in *SetDeliveryRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
|
||||||
|
SetPickupPoint(ctx context.Context, in *SetPickupPointRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
|
||||||
|
// (Checkout RPC removed - handled externally)
|
||||||
|
SetCartItems(ctx context.Context, in *SetCartItemsRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
|
||||||
|
OrderCompleted(ctx context.Context, in *OrderCompletedRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
|
||||||
GetState(ctx context.Context, in *StateRequest, opts ...grpc.CallOption) (*StateReply, error)
|
GetState(ctx context.Context, in *StateRequest, opts ...grpc.CallOption) (*StateReply, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,10 +60,90 @@ func NewCartActorClient(cc grpc.ClientConnInterface) CartActorClient {
|
|||||||
return &cartActorClient{cc}
|
return &cartActorClient{cc}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *cartActorClient) Mutate(ctx context.Context, in *MutationEnvelope, opts ...grpc.CallOption) (*MutationReply, error) {
|
func (c *cartActorClient) AddRequest(ctx context.Context, in *AddRequestRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(MutationReply)
|
out := new(CartMutationReply)
|
||||||
err := c.cc.Invoke(ctx, CartActor_Mutate_FullMethodName, in, out, cOpts...)
|
err := c.cc.Invoke(ctx, CartActor_AddRequest_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cartActorClient) AddItem(ctx context.Context, in *AddItemRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CartMutationReply)
|
||||||
|
err := c.cc.Invoke(ctx, CartActor_AddItem_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cartActorClient) RemoveItem(ctx context.Context, in *RemoveItemRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CartMutationReply)
|
||||||
|
err := c.cc.Invoke(ctx, CartActor_RemoveItem_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cartActorClient) RemoveDelivery(ctx context.Context, in *RemoveDeliveryRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CartMutationReply)
|
||||||
|
err := c.cc.Invoke(ctx, CartActor_RemoveDelivery_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cartActorClient) ChangeQuantity(ctx context.Context, in *ChangeQuantityRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CartMutationReply)
|
||||||
|
err := c.cc.Invoke(ctx, CartActor_ChangeQuantity_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cartActorClient) SetDelivery(ctx context.Context, in *SetDeliveryRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CartMutationReply)
|
||||||
|
err := c.cc.Invoke(ctx, CartActor_SetDelivery_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cartActorClient) SetPickupPoint(ctx context.Context, in *SetPickupPointRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CartMutationReply)
|
||||||
|
err := c.cc.Invoke(ctx, CartActor_SetPickupPoint_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cartActorClient) SetCartItems(ctx context.Context, in *SetCartItemsRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CartMutationReply)
|
||||||
|
err := c.cc.Invoke(ctx, CartActor_SetCartItems_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cartActorClient) OrderCompleted(ctx context.Context, in *OrderCompletedRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CartMutationReply)
|
||||||
|
err := c.cc.Invoke(ctx, CartActor_OrderCompleted_FullMethodName, in, out, cOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -67,11 +164,20 @@ func (c *cartActorClient) GetState(ctx context.Context, in *StateRequest, opts .
|
|||||||
// All implementations must embed UnimplementedCartActorServer
|
// All implementations must embed UnimplementedCartActorServer
|
||||||
// for forward compatibility.
|
// for forward compatibility.
|
||||||
//
|
//
|
||||||
// CartActor exposes mutation and state retrieval for remote grains.
|
// -----------------------------------------------------------------------------
|
||||||
|
// Service definition (per-mutation RPCs + checkout)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
type CartActorServer interface {
|
type CartActorServer interface {
|
||||||
// Mutate applies a single mutation to a cart, creating the cart lazily if needed.
|
AddRequest(context.Context, *AddRequestRequest) (*CartMutationReply, error)
|
||||||
Mutate(context.Context, *MutationEnvelope) (*MutationReply, error)
|
AddItem(context.Context, *AddItemRequest) (*CartMutationReply, error)
|
||||||
// GetState retrieves the cart's current state (JSON).
|
RemoveItem(context.Context, *RemoveItemRequest) (*CartMutationReply, error)
|
||||||
|
RemoveDelivery(context.Context, *RemoveDeliveryRequest) (*CartMutationReply, error)
|
||||||
|
ChangeQuantity(context.Context, *ChangeQuantityRequest) (*CartMutationReply, error)
|
||||||
|
SetDelivery(context.Context, *SetDeliveryRequest) (*CartMutationReply, error)
|
||||||
|
SetPickupPoint(context.Context, *SetPickupPointRequest) (*CartMutationReply, error)
|
||||||
|
// (Checkout RPC removed - handled externally)
|
||||||
|
SetCartItems(context.Context, *SetCartItemsRequest) (*CartMutationReply, error)
|
||||||
|
OrderCompleted(context.Context, *OrderCompletedRequest) (*CartMutationReply, error)
|
||||||
GetState(context.Context, *StateRequest) (*StateReply, error)
|
GetState(context.Context, *StateRequest) (*StateReply, error)
|
||||||
mustEmbedUnimplementedCartActorServer()
|
mustEmbedUnimplementedCartActorServer()
|
||||||
}
|
}
|
||||||
@@ -83,8 +189,32 @@ type CartActorServer interface {
|
|||||||
// pointer dereference when methods are called.
|
// pointer dereference when methods are called.
|
||||||
type UnimplementedCartActorServer struct{}
|
type UnimplementedCartActorServer struct{}
|
||||||
|
|
||||||
func (UnimplementedCartActorServer) Mutate(context.Context, *MutationEnvelope) (*MutationReply, error) {
|
func (UnimplementedCartActorServer) AddRequest(context.Context, *AddRequestRequest) (*CartMutationReply, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method Mutate not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method AddRequest not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedCartActorServer) AddItem(context.Context, *AddItemRequest) (*CartMutationReply, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method AddItem not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedCartActorServer) RemoveItem(context.Context, *RemoveItemRequest) (*CartMutationReply, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method RemoveItem not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedCartActorServer) RemoveDelivery(context.Context, *RemoveDeliveryRequest) (*CartMutationReply, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method RemoveDelivery not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedCartActorServer) ChangeQuantity(context.Context, *ChangeQuantityRequest) (*CartMutationReply, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method ChangeQuantity not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedCartActorServer) SetDelivery(context.Context, *SetDeliveryRequest) (*CartMutationReply, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method SetDelivery not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedCartActorServer) SetPickupPoint(context.Context, *SetPickupPointRequest) (*CartMutationReply, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method SetPickupPoint not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedCartActorServer) SetCartItems(context.Context, *SetCartItemsRequest) (*CartMutationReply, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method SetCartItems not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedCartActorServer) OrderCompleted(context.Context, *OrderCompletedRequest) (*CartMutationReply, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method OrderCompleted not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedCartActorServer) GetState(context.Context, *StateRequest) (*StateReply, error) {
|
func (UnimplementedCartActorServer) GetState(context.Context, *StateRequest) (*StateReply, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method GetState not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method GetState not implemented")
|
||||||
@@ -110,20 +240,164 @@ func RegisterCartActorServer(s grpc.ServiceRegistrar, srv CartActorServer) {
|
|||||||
s.RegisterService(&CartActor_ServiceDesc, srv)
|
s.RegisterService(&CartActor_ServiceDesc, srv)
|
||||||
}
|
}
|
||||||
|
|
||||||
func _CartActor_Mutate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
func _CartActor_AddRequest_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(MutationEnvelope)
|
in := new(AddRequestRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if interceptor == nil {
|
if interceptor == nil {
|
||||||
return srv.(CartActorServer).Mutate(ctx, in)
|
return srv.(CartActorServer).AddRequest(ctx, in)
|
||||||
}
|
}
|
||||||
info := &grpc.UnaryServerInfo{
|
info := &grpc.UnaryServerInfo{
|
||||||
Server: srv,
|
Server: srv,
|
||||||
FullMethod: CartActor_Mutate_FullMethodName,
|
FullMethod: CartActor_AddRequest_FullMethodName,
|
||||||
}
|
}
|
||||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
return srv.(CartActorServer).Mutate(ctx, req.(*MutationEnvelope))
|
return srv.(CartActorServer).AddRequest(ctx, req.(*AddRequestRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _CartActor_AddItem_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(AddItemRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(CartActorServer).AddItem(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: CartActor_AddItem_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(CartActorServer).AddItem(ctx, req.(*AddItemRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _CartActor_RemoveItem_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(RemoveItemRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(CartActorServer).RemoveItem(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: CartActor_RemoveItem_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(CartActorServer).RemoveItem(ctx, req.(*RemoveItemRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _CartActor_RemoveDelivery_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(RemoveDeliveryRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(CartActorServer).RemoveDelivery(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: CartActor_RemoveDelivery_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(CartActorServer).RemoveDelivery(ctx, req.(*RemoveDeliveryRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _CartActor_ChangeQuantity_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(ChangeQuantityRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(CartActorServer).ChangeQuantity(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: CartActor_ChangeQuantity_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(CartActorServer).ChangeQuantity(ctx, req.(*ChangeQuantityRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _CartActor_SetDelivery_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(SetDeliveryRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(CartActorServer).SetDelivery(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: CartActor_SetDelivery_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(CartActorServer).SetDelivery(ctx, req.(*SetDeliveryRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _CartActor_SetPickupPoint_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(SetPickupPointRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(CartActorServer).SetPickupPoint(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: CartActor_SetPickupPoint_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(CartActorServer).SetPickupPoint(ctx, req.(*SetPickupPointRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _CartActor_SetCartItems_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(SetCartItemsRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(CartActorServer).SetCartItems(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: CartActor_SetCartItems_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(CartActorServer).SetCartItems(ctx, req.(*SetCartItemsRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _CartActor_OrderCompleted_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(OrderCompletedRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(CartActorServer).OrderCompleted(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: CartActor_OrderCompleted_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(CartActorServer).OrderCompleted(ctx, req.(*OrderCompletedRequest))
|
||||||
}
|
}
|
||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
@@ -154,8 +428,40 @@ var CartActor_ServiceDesc = grpc.ServiceDesc{
|
|||||||
HandlerType: (*CartActorServer)(nil),
|
HandlerType: (*CartActorServer)(nil),
|
||||||
Methods: []grpc.MethodDesc{
|
Methods: []grpc.MethodDesc{
|
||||||
{
|
{
|
||||||
MethodName: "Mutate",
|
MethodName: "AddRequest",
|
||||||
Handler: _CartActor_Mutate_Handler,
|
Handler: _CartActor_AddRequest_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "AddItem",
|
||||||
|
Handler: _CartActor_AddItem_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "RemoveItem",
|
||||||
|
Handler: _CartActor_RemoveItem_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "RemoveDelivery",
|
||||||
|
Handler: _CartActor_RemoveDelivery_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "ChangeQuantity",
|
||||||
|
Handler: _CartActor_ChangeQuantity_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "SetDelivery",
|
||||||
|
Handler: _CartActor_SetDelivery_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "SetPickupPoint",
|
||||||
|
Handler: _CartActor_SetPickupPoint_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "SetCartItems",
|
||||||
|
Handler: _CartActor_SetCartItems_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "OrderCompleted",
|
||||||
|
Handler: _CartActor_OrderCompleted_Handler,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
MethodName: "GetState",
|
MethodName: "GetState",
|
||||||
|
|||||||
@@ -889,6 +889,102 @@ func (x *OrderCreated) GetStatus() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Noop struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Noop) Reset() {
|
||||||
|
*x = Noop{}
|
||||||
|
mi := &file_messages_proto_msgTypes[11]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Noop) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Noop) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *Noop) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_messages_proto_msgTypes[11]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use Noop.ProtoReflect.Descriptor instead.
|
||||||
|
func (*Noop) Descriptor() ([]byte, []int) {
|
||||||
|
return file_messages_proto_rawDescGZIP(), []int{11}
|
||||||
|
}
|
||||||
|
|
||||||
|
type InitializeCheckout struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
OrderId string `protobuf:"bytes,1,opt,name=orderId,proto3" json:"orderId,omitempty"`
|
||||||
|
Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"`
|
||||||
|
PaymentInProgress bool `protobuf:"varint,3,opt,name=paymentInProgress,proto3" json:"paymentInProgress,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *InitializeCheckout) Reset() {
|
||||||
|
*x = InitializeCheckout{}
|
||||||
|
mi := &file_messages_proto_msgTypes[12]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *InitializeCheckout) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*InitializeCheckout) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *InitializeCheckout) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_messages_proto_msgTypes[12]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use InitializeCheckout.ProtoReflect.Descriptor instead.
|
||||||
|
func (*InitializeCheckout) Descriptor() ([]byte, []int) {
|
||||||
|
return file_messages_proto_rawDescGZIP(), []int{12}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *InitializeCheckout) GetOrderId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.OrderId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *InitializeCheckout) GetStatus() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Status
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *InitializeCheckout) GetPaymentInProgress() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.PaymentInProgress
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
var File_messages_proto protoreflect.FileDescriptor
|
var File_messages_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
const file_messages_proto_rawDesc = "" +
|
const file_messages_proto_rawDesc = "" +
|
||||||
@@ -997,7 +1093,12 @@ const file_messages_proto_rawDesc = "" +
|
|||||||
"\acountry\x18\x06 \x01(\tR\acountry\"@\n" +
|
"\acountry\x18\x06 \x01(\tR\acountry\"@\n" +
|
||||||
"\fOrderCreated\x12\x18\n" +
|
"\fOrderCreated\x12\x18\n" +
|
||||||
"\aorderId\x18\x01 \x01(\tR\aorderId\x12\x16\n" +
|
"\aorderId\x18\x01 \x01(\tR\aorderId\x12\x16\n" +
|
||||||
"\x06status\x18\x02 \x01(\tR\x06statusB.Z,git.tornberg.me/go-cart-actor/proto;messagesb\x06proto3"
|
"\x06status\x18\x02 \x01(\tR\x06status\"\x06\n" +
|
||||||
|
"\x04Noop\"t\n" +
|
||||||
|
"\x12InitializeCheckout\x12\x18\n" +
|
||||||
|
"\aorderId\x18\x01 \x01(\tR\aorderId\x12\x16\n" +
|
||||||
|
"\x06status\x18\x02 \x01(\tR\x06status\x12,\n" +
|
||||||
|
"\x11paymentInProgress\x18\x03 \x01(\bR\x11paymentInProgressB.Z,git.tornberg.me/go-cart-actor/proto;messagesb\x06proto3"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
file_messages_proto_rawDescOnce sync.Once
|
file_messages_proto_rawDescOnce sync.Once
|
||||||
@@ -1011,7 +1112,7 @@ func file_messages_proto_rawDescGZIP() []byte {
|
|||||||
return file_messages_proto_rawDescData
|
return file_messages_proto_rawDescData
|
||||||
}
|
}
|
||||||
|
|
||||||
var file_messages_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
|
var file_messages_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
|
||||||
var file_messages_proto_goTypes = []any{
|
var file_messages_proto_goTypes = []any{
|
||||||
(*AddRequest)(nil), // 0: messages.AddRequest
|
(*AddRequest)(nil), // 0: messages.AddRequest
|
||||||
(*SetCartRequest)(nil), // 1: messages.SetCartRequest
|
(*SetCartRequest)(nil), // 1: messages.SetCartRequest
|
||||||
@@ -1024,6 +1125,8 @@ var file_messages_proto_goTypes = []any{
|
|||||||
(*RemoveDelivery)(nil), // 8: messages.RemoveDelivery
|
(*RemoveDelivery)(nil), // 8: messages.RemoveDelivery
|
||||||
(*CreateCheckoutOrder)(nil), // 9: messages.CreateCheckoutOrder
|
(*CreateCheckoutOrder)(nil), // 9: messages.CreateCheckoutOrder
|
||||||
(*OrderCreated)(nil), // 10: messages.OrderCreated
|
(*OrderCreated)(nil), // 10: messages.OrderCreated
|
||||||
|
(*Noop)(nil), // 11: messages.Noop
|
||||||
|
(*InitializeCheckout)(nil), // 12: messages.InitializeCheckout
|
||||||
}
|
}
|
||||||
var file_messages_proto_depIdxs = []int32{
|
var file_messages_proto_depIdxs = []int32{
|
||||||
0, // 0: messages.SetCartRequest.items:type_name -> messages.AddRequest
|
0, // 0: messages.SetCartRequest.items:type_name -> messages.AddRequest
|
||||||
@@ -1051,7 +1154,7 @@ func file_messages_proto_init() {
|
|||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_messages_proto_rawDesc), len(file_messages_proto_rawDesc)),
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_messages_proto_rawDesc), len(file_messages_proto_rawDesc)),
|
||||||
NumEnums: 0,
|
NumEnums: 0,
|
||||||
NumMessages: 11,
|
NumMessages: 13,
|
||||||
NumExtensions: 0,
|
NumExtensions: 0,
|
||||||
NumServices: 0,
|
NumServices: 0,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -93,3 +93,13 @@ message OrderCreated {
|
|||||||
string orderId = 1;
|
string orderId = 1;
|
||||||
string status = 2;
|
string status = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message Noop {
|
||||||
|
// Intentionally empty - used for ownership acquisition or health pings
|
||||||
|
}
|
||||||
|
|
||||||
|
message InitializeCheckout {
|
||||||
|
string orderId = 1;
|
||||||
|
string status = 2;
|
||||||
|
bool paymentInProgress = 3;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
// package main
|
|
||||||
|
|
||||||
// import "sync"
|
|
||||||
|
|
||||||
// type RemoteGrainPool struct {
|
|
||||||
// mu sync.RWMutex
|
|
||||||
// Host string
|
|
||||||
// grains map[CartId]*RemoteGrain
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func NewRemoteGrainPool(addr string) *RemoteGrainPool {
|
|
||||||
// return &RemoteGrainPool{
|
|
||||||
// Host: addr,
|
|
||||||
// grains: make(map[CartId]*RemoteGrain),
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func (p *RemoteGrainPool) findRemoteGrain(id CartId) *RemoteGrain {
|
|
||||||
// p.mu.RLock()
|
|
||||||
// grain, ok := p.grains[id]
|
|
||||||
// p.mu.RUnlock()
|
|
||||||
// if !ok {
|
|
||||||
// return nil
|
|
||||||
// }
|
|
||||||
// return grain
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func (p *RemoteGrainPool) findOrCreateGrain(id CartId) (*RemoteGrain, error) {
|
|
||||||
// grain := p.findRemoteGrain(id)
|
|
||||||
|
|
||||||
// if grain == nil {
|
|
||||||
// grain, err := NewRemoteGrain(id, p.Host)
|
|
||||||
// if err != nil {
|
|
||||||
// return nil, err
|
|
||||||
// }
|
|
||||||
// p.mu.Lock()
|
|
||||||
// p.grains[id] = grain
|
|
||||||
// p.mu.Unlock()
|
|
||||||
// }
|
|
||||||
// return grain, nil
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func (p *RemoteGrainPool) Delete(id CartId) {
|
|
||||||
// p.mu.Lock()
|
|
||||||
// delete(p.grains, id)
|
|
||||||
// p.mu.Unlock()
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func (p *RemoteGrainPool) Process(id CartId, messages ...Message) (*FrameWithPayload, error) {
|
|
||||||
// var result *FrameWithPayload
|
|
||||||
// grain, err := p.findOrCreateGrain(id)
|
|
||||||
// if err != nil {
|
|
||||||
// return nil, err
|
|
||||||
// }
|
|
||||||
// for _, message := range messages {
|
|
||||||
// result, err = grain.HandleMessage(&message, false)
|
|
||||||
// }
|
|
||||||
// return result, err
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func (p *RemoteGrainPool) Get(id CartId) (*FrameWithPayload, error) {
|
|
||||||
// grain, err := p.findOrCreateGrain(id)
|
|
||||||
// if err != nil {
|
|
||||||
// return nil, err
|
|
||||||
// }
|
|
||||||
// return grain.GetCurrentState()
|
|
||||||
// }
|
|
||||||
@@ -67,8 +67,8 @@ func (g *RemoteGrainGRPC) GetId() CartId {
|
|||||||
return g.Id
|
return g.Id
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply executes a cart mutation remotely using the typed oneof MutationEnvelope
|
// Apply executes a cart mutation via per-mutation RPCs (breaking v2 API)
|
||||||
// and returns a *CartGrain reconstructed from the typed MutationReply (state oneof).
|
// and returns a *CartGrain reconstructed from the CartMutationReply state.
|
||||||
func (g *RemoteGrainGRPC) Apply(content interface{}, isReplay bool) (*CartGrain, error) {
|
func (g *RemoteGrainGRPC) Apply(content interface{}, isReplay bool) (*CartGrain, error) {
|
||||||
if isReplay {
|
if isReplay {
|
||||||
return nil, fmt.Errorf("replay not supported for remote grains")
|
return nil, fmt.Errorf("replay not supported for remote grains")
|
||||||
@@ -78,45 +78,100 @@ func (g *RemoteGrainGRPC) Apply(content interface{}, isReplay bool) (*CartGrain,
|
|||||||
}
|
}
|
||||||
|
|
||||||
ts := time.Now().Unix()
|
ts := time.Now().Unix()
|
||||||
env := &proto.MutationEnvelope{
|
|
||||||
CartId: g.Id.String(),
|
var invoke func(ctx context.Context) (*proto.CartMutationReply, error)
|
||||||
ClientTimestamp: ts,
|
|
||||||
}
|
|
||||||
|
|
||||||
switch m := content.(type) {
|
switch m := content.(type) {
|
||||||
case *proto.AddRequest:
|
case *proto.AddRequest:
|
||||||
env.Mutation = &proto.MutationEnvelope_AddRequest{AddRequest: m}
|
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
|
||||||
|
return g.client.AddRequest(ctx, &proto.AddRequestRequest{
|
||||||
|
CartId: g.Id.String(),
|
||||||
|
ClientTimestamp: ts,
|
||||||
|
Payload: m,
|
||||||
|
})
|
||||||
|
}
|
||||||
case *proto.AddItem:
|
case *proto.AddItem:
|
||||||
env.Mutation = &proto.MutationEnvelope_AddItem{AddItem: m}
|
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
|
||||||
|
return g.client.AddItem(ctx, &proto.AddItemRequest{
|
||||||
|
CartId: g.Id.String(),
|
||||||
|
ClientTimestamp: ts,
|
||||||
|
Payload: m,
|
||||||
|
})
|
||||||
|
}
|
||||||
case *proto.RemoveItem:
|
case *proto.RemoveItem:
|
||||||
env.Mutation = &proto.MutationEnvelope_RemoveItem{RemoveItem: m}
|
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
|
||||||
|
return g.client.RemoveItem(ctx, &proto.RemoveItemRequest{
|
||||||
|
CartId: g.Id.String(),
|
||||||
|
ClientTimestamp: ts,
|
||||||
|
Payload: m,
|
||||||
|
})
|
||||||
|
}
|
||||||
case *proto.RemoveDelivery:
|
case *proto.RemoveDelivery:
|
||||||
env.Mutation = &proto.MutationEnvelope_RemoveDelivery{RemoveDelivery: m}
|
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
|
||||||
|
return g.client.RemoveDelivery(ctx, &proto.RemoveDeliveryRequest{
|
||||||
|
CartId: g.Id.String(),
|
||||||
|
ClientTimestamp: ts,
|
||||||
|
Payload: m,
|
||||||
|
})
|
||||||
|
}
|
||||||
case *proto.ChangeQuantity:
|
case *proto.ChangeQuantity:
|
||||||
env.Mutation = &proto.MutationEnvelope_ChangeQuantity{ChangeQuantity: m}
|
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
|
||||||
|
return g.client.ChangeQuantity(ctx, &proto.ChangeQuantityRequest{
|
||||||
|
CartId: g.Id.String(),
|
||||||
|
ClientTimestamp: ts,
|
||||||
|
Payload: m,
|
||||||
|
})
|
||||||
|
}
|
||||||
case *proto.SetDelivery:
|
case *proto.SetDelivery:
|
||||||
env.Mutation = &proto.MutationEnvelope_SetDelivery{SetDelivery: m}
|
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
|
||||||
|
return g.client.SetDelivery(ctx, &proto.SetDeliveryRequest{
|
||||||
|
CartId: g.Id.String(),
|
||||||
|
ClientTimestamp: ts,
|
||||||
|
Payload: m,
|
||||||
|
})
|
||||||
|
}
|
||||||
case *proto.SetPickupPoint:
|
case *proto.SetPickupPoint:
|
||||||
env.Mutation = &proto.MutationEnvelope_SetPickupPoint{SetPickupPoint: m}
|
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
|
||||||
|
return g.client.SetPickupPoint(ctx, &proto.SetPickupPointRequest{
|
||||||
|
CartId: g.Id.String(),
|
||||||
|
ClientTimestamp: ts,
|
||||||
|
Payload: m,
|
||||||
|
})
|
||||||
|
}
|
||||||
case *proto.CreateCheckoutOrder:
|
case *proto.CreateCheckoutOrder:
|
||||||
env.Mutation = &proto.MutationEnvelope_CreateCheckoutOrder{CreateCheckoutOrder: m}
|
return nil, fmt.Errorf("CreateCheckoutOrder deprecated: checkout is handled via HTTP endpoint (HandleCheckout)")
|
||||||
case *proto.SetCartRequest:
|
case *proto.SetCartRequest:
|
||||||
env.Mutation = &proto.MutationEnvelope_SetCartItems{SetCartItems: m}
|
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
|
||||||
|
return g.client.SetCartItems(ctx, &proto.SetCartItemsRequest{
|
||||||
|
CartId: g.Id.String(),
|
||||||
|
ClientTimestamp: ts,
|
||||||
|
Payload: m,
|
||||||
|
})
|
||||||
|
}
|
||||||
case *proto.OrderCreated:
|
case *proto.OrderCreated:
|
||||||
env.Mutation = &proto.MutationEnvelope_OrderCompleted{OrderCompleted: m}
|
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
|
||||||
|
return g.client.OrderCompleted(ctx, &proto.OrderCompletedRequest{
|
||||||
|
CartId: g.Id.String(),
|
||||||
|
ClientTimestamp: ts,
|
||||||
|
Payload: m,
|
||||||
|
})
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported mutation type %T", content)
|
return nil, fmt.Errorf("unsupported mutation type %T", content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if invoke == nil {
|
||||||
|
return nil, fmt.Errorf("no invocation mapped for mutation %T", content)
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), g.mutateTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), g.mutateTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
resp, err := g.client.Mutate(ctx, env)
|
resp, err := invoke(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map typed reply
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
if e := resp.GetError(); e != "" {
|
if e := resp.GetError(); e != "" {
|
||||||
return nil, fmt.Errorf("remote mutation failed %d: %s", resp.StatusCode, e)
|
return nil, fmt.Errorf("remote mutation failed %d: %s", resp.StatusCode, e)
|
||||||
|
|||||||
@@ -426,20 +426,13 @@ func (p *SyncedPool) getGrain(id CartId) (Grain, error) {
|
|||||||
return grain, nil
|
return grain, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process applies mutation(s) to a grain (local or remote).
|
// Apply applies a single mutation to a grain (local or remote).
|
||||||
func (p *SyncedPool) Process(id CartId, mutations ...interface{}) (*CartGrain, error) {
|
func (p *SyncedPool) Apply(id CartId, mutation interface{}) (*CartGrain, error) {
|
||||||
grain, err := p.getGrain(id)
|
grain, err := p.getGrain(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var res *CartGrain
|
return grain.Apply(mutation, false)
|
||||||
for _, m := range mutations {
|
|
||||||
res, err = grain.Apply(m, false)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get returns current state of a grain (local or remote).
|
// Get returns current state of a grain (local or remote).
|
||||||
|
|||||||
Reference in New Issue
Block a user