From 716f1121aa6fd0a41dd2f2cbc69fdf2256e3fdb7 Mon Sep 17 00:00:00 2001 From: matst80 Date: Fri, 10 Oct 2025 11:46:19 +0000 Subject: [PATCH] even more refactoring --- README.md | 238 +++++- TODO.md | 245 ++++++ amqp-order-handler.go | 61 ++ cart-grain.go | 236 +----- checkout_builder.go | 119 +++ cookies.txt | 5 + go.mod | 1 + go.sum | 6 + grain-pool.go | 16 +- grpc_integration_test.go | 31 +- grpc_server.go | 170 ++-- main.go | 264 +++++- mutation_add_item.go | 82 ++ mutation_add_request.go | 61 ++ mutation_change_quantity.go | 58 ++ mutation_initialize_checkout.go | 49 ++ mutation_order_created.go | 53 ++ mutation_registry.go | 301 +++++++ mutation_remove_delivery.go | 53 ++ mutation_remove_item.go | 49 ++ mutation_set_cart_items.go | 57 ++ mutation_set_delivery.go | 101 +++ mutation_set_pickup_point.go | 56 ++ pool-server.go | 351 ++++++++ proto/cart_actor.pb.go | 1347 ++++++++++++++++++++----------- proto/cart_actor.proto | 160 ++-- proto/cart_actor_grpc.pb.go | 350 +++++++- proto/messages.pb.go | 109 ++- proto/messages.proto | 10 + remote-grain-pool._go | 67 -- remote_grain_grpc.go | 91 ++- synced-pool.go | 13 +- 32 files changed, 3857 insertions(+), 953 deletions(-) create mode 100644 TODO.md create mode 100644 amqp-order-handler.go create mode 100644 checkout_builder.go create mode 100644 cookies.txt create mode 100644 mutation_add_item.go create mode 100644 mutation_add_request.go create mode 100644 mutation_change_quantity.go create mode 100644 mutation_initialize_checkout.go create mode 100644 mutation_order_created.go create mode 100644 mutation_registry.go create mode 100644 mutation_remove_delivery.go create mode 100644 mutation_remove_item.go create mode 100644 mutation_set_cart_items.go create mode 100644 mutation_set_delivery.go create mode 100644 mutation_set_pickup_point.go create mode 100644 pool-server.go delete mode 100644 remote-grain-pool._go diff --git a/README.md b/README.md index 6db9855..67521f7 100644 --- a/README.md +++ b/README.md @@ -175,4 +175,240 @@ 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) - 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`) \ No newline at end of file +- 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 | + +--- diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..3ec9585 --- /dev/null +++ b/TODO.md @@ -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._ \ No newline at end of file diff --git a/amqp-order-handler.go b/amqp-order-handler.go new file mode 100644 index 0000000..5003732 --- /dev/null +++ b/amqp-order-handler.go @@ -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 +} diff --git a/cart-grain.go b/cart-grain.go index ffca614..7f7dd99 100644 --- a/cart-grain.go +++ b/cart-grain.go @@ -1,11 +1,8 @@ package main import ( - "bytes" "encoding/json" "fmt" - "log" - "slices" "sync" 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) { grainMutations.Inc() - switch msg := content.(type) { - - 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) + updated, err := ApplyRegistered(c, content) + if err != nil { + if err == ErrMutationNotRegistered { + return nil, fmt.Errorf("unsupported mutation type %T (not registered)", content) } - - 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 { - return nil, err - } - var klarnaOrder *CheckoutOrder - 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) + return nil, err } - - // (Optional) Append to new storage mechanism here if still required. - - return c, nil + return updated, nil } func (c *CartGrain) UpdateTotals() { diff --git a/checkout_builder.go b/checkout_builder.go new file mode 100644 index 0000000..c7bb53a --- /dev/null +++ b/checkout_builder.go @@ -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 +} diff --git a/cookies.txt b/cookies.txt new file mode 100644 index 0000000..be2ec67 --- /dev/null +++ b/cookies.txt @@ -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 diff --git a/go.mod b/go.mod index 3baefef..0148cea 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/google/uuid v1.6.0 github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548 github.com/prometheus/client_golang v1.23.2 + github.com/rabbitmq/amqp091-go v1.10.0 google.golang.org/grpc v1.76.0 google.golang.org/protobuf v1.36.10 k8s.io/api v0.34.1 diff --git a/go.sum b/go.sum index ef3aab2..7c2ab17 100644 --- a/go.sum +++ b/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/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 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/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/go.mod h1:k4lo5gFYb3AqrgftlraCnv95xvjB/w8udRqJQJ12mAE= 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/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= +github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= +github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= diff --git a/grain-pool.go b/grain-pool.go index 8391948..43cf88e 100644 --- a/grain-pool.go +++ b/grain-pool.go @@ -26,7 +26,7 @@ var ( ) type GrainPool interface { - Process(id CartId, mutations ...interface{}) (*CartGrain, error) + Apply(id CartId, mutation interface{}) (*CartGrain, error) Get(id CartId) (*CartGrain, error) } @@ -142,18 +142,12 @@ func (p *GrainLocalPool) GetGrain(id CartId) (*CartGrain, error) { 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) - var result *CartGrain - if err == nil && grain != nil { - for _, m := range mutations { - result, err = grain.Apply(m, false) - if err != nil { - break - } - } + if err != nil || grain == nil { + return nil, err } - return result, err + return grain.Apply(mutation, false) } func (p *GrainLocalPool) Get(id CartId) (*CartGrain, error) { diff --git a/grpc_integration_test.go b/grpc_integration_test.go index 3e54581..863bd7f 100644 --- a/grpc_integration_test.go +++ b/grpc_integration_test.go @@ -12,7 +12,7 @@ import ( // TestCartActorMutationAndState validates end-to-end gRPC mutation + state retrieval // 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. func TestCartActorMutationAndState(t *testing.T) { // Setup local grain pool + synced pool (no discovery, single host) @@ -60,32 +60,29 @@ func TestCartActorMutationAndState(t *testing.T) { Country: "se", } - // Build oneof envelope directly (no legacy handler/enum) - envelope := &messages.MutationEnvelope{ + // Issue AddItem RPC directly (breaking v2 API) + addResp, err := cartClient.AddItem(context.Background(), &messages.AddItemRequest{ CartId: cartID, ClientTimestamp: time.Now().Unix(), - Mutation: &messages.MutationEnvelope_AddItem{ - AddItem: addItem, - }, - } - - // Issue Mutate RPC - mutResp, err := cartClient.Mutate(context.Background(), envelope) + Payload: addItem, + }) if err != nil { - t.Fatalf("Mutate RPC error: %v", err) + t.Fatalf("AddItem RPC error: %v", err) } - if mutResp.StatusCode != 200 { - t.Fatalf("Mutate returned non-200 status: %d, error: %s", mutResp.StatusCode, mutResp.GetError()) + if addResp.StatusCode != 200 { + t.Fatalf("AddItem returned non-200 status: %d, error: %s", addResp.StatusCode, addResp.GetError()) } - // Validate the response state - state := mutResp.GetState() + // Validate the response state (from AddItem) + state := addResp.GetState() 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 { - 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" { t.Fatalf("Unexpected item SKU: %s", state.Items[0].Sku) diff --git a/grpc_server.go b/grpc_server.go index c5b2a42..06c220f 100644 --- a/grpc_server.go +++ b/grpc_server.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "net" + "time" messages "git.tornberg.me/go-cart-actor/proto" "google.golang.org/grpc" @@ -17,7 +18,7 @@ type cartActorGRPCServer struct { messages.UnimplementedCartActorServer messages.UnimplementedControlPlaneServer - pool GrainPool // For cart state mutations and queries + pool GrainPool // For cart state mutations and queries syncedPool *SyncedPool // For cluster membership and control } @@ -29,62 +30,125 @@ func NewCartActorGRPCServer(pool GrainPool, syncedPool *SyncedPool) *cartActorGR } } -// Mutate applies a mutation from an envelope to the corresponding cart grain. -func (s *cartActorGRPCServer) Mutate(ctx context.Context, envelope *messages.MutationEnvelope) (*messages.MutationReply, error) { - if envelope.GetCartId() == "" { - 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) +// applyMutation routes a single cart mutation to the target grain (used by per-mutation RPC handlers). +func (s *cartActorGRPCServer) applyMutation(cartID string, mutation interface{}) *messages.CartMutationReply { + grain, err := s.pool.Apply(ToCartId(cartID), mutation) if err != nil { - return &messages.MutationReply{ - StatusCode: 500, - Result: &messages.MutationReply_Error{Error: err.Error()}, + return &messages.CartMutationReply{ + StatusCode: 500, + Result: &messages.CartMutationReply_Error{Error: err.Error()}, + ServerTimestamp: time.Now().Unix(), + } + } + cartState := ToCartState(grain) + return &messages.CartMutationReply{ + StatusCode: 200, + Result: &messages.CartMutationReply_State{State: cartState}, + ServerTimestamp: time.Now().Unix(), + } +} + +func (s *cartActorGRPCServer) AddRequest(ctx context.Context, req *messages.AddRequestRequest) (*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 +} - // Map the internal grain state to the protobuf representation. - cartState := ToCartState(grain) +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 +} - return &messages.MutationReply{ - StatusCode: 200, - Result: &messages.MutationReply_State{State: cartState}, - }, 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. @@ -136,4 +200,4 @@ func StartGRPCServer(addr string, pool GrainPool, syncedPool *SyncedPool) (*grpc }() return grpcServer, nil -} \ No newline at end of file +} diff --git a/main.go b/main.go index 1e32e7c..a5e33a3 100644 --- a/main.go +++ b/main.go @@ -1,15 +1,21 @@ package main import ( + "encoding/json" "fmt" "log" + "net/http" + "net/http/pprof" "os" "os/signal" + "strings" "syscall" "time" + messages "git.tornberg.me/go-cart-actor/proto" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" ) @@ -76,13 +82,60 @@ func (a *App) Save() error { 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 name = os.Getenv("POD_NAME") var amqpUrl = os.Getenv("AMQP_URL") var KlarnaInstance = NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD")) +var tpl = ` + + + + + s10r testing - checkout + + + + %s + + +` + +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 { if podIp == "" { return nil @@ -100,8 +153,6 @@ func GetDiscovery() Discovery { return NewK8sDiscovery(client) } - - func main() { 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) done := make(chan bool, 1) @@ -148,7 +377,34 @@ func main() { done <- true }() - log.Print("Server started at port 1337") + log.Print("Server started at port 8083") + go http.ListenAndServe(":8083", mux) <-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 +} diff --git a/mutation_add_item.go b/mutation_add_item.go new file mode 100644 index 0000000..6053d3d --- /dev/null +++ b/mutation_add_item.go @@ -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 + ) +} diff --git a/mutation_add_request.go b/mutation_add_request.go new file mode 100644 index 0000000..a4ad874 --- /dev/null +++ b/mutation_add_request.go @@ -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(), + ) +} diff --git a/mutation_change_quantity.go b/mutation_change_quantity.go new file mode 100644 index 0000000..d311ce9 --- /dev/null +++ b/mutation_change_quantity.go @@ -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(), + ) +} diff --git a/mutation_initialize_checkout.go b/mutation_initialize_checkout.go new file mode 100644 index 0000000..f43c128 --- /dev/null +++ b/mutation_initialize_checkout.go @@ -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. + ) +} diff --git a/mutation_order_created.go b/mutation_order_created.go new file mode 100644 index 0000000..b83a2cb --- /dev/null +++ b/mutation_order_created.go @@ -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. + ) +} diff --git a/mutation_registry.go b/mutation_registry.go new file mode 100644 index 0000000..975ec22 --- /dev/null +++ b/mutation_registry.go @@ -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. + +*/ diff --git a/mutation_remove_delivery.go b/mutation_remove_delivery.go new file mode 100644 index 0000000..49e9dcf --- /dev/null +++ b/mutation_remove_delivery.go @@ -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(), + ) +} diff --git a/mutation_remove_item.go b/mutation_remove_item.go new file mode 100644 index 0000000..420eff0 --- /dev/null +++ b/mutation_remove_item.go @@ -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(), + ) +} diff --git a/mutation_set_cart_items.go b/mutation_set_cart_items.go new file mode 100644 index 0000000..aa811a7 --- /dev/null +++ b/mutation_set_cart_items.go @@ -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(), + ) +} diff --git a/mutation_set_delivery.go b/mutation_set_delivery.go new file mode 100644 index 0000000..3cea3c7 --- /dev/null +++ b/mutation_set_delivery.go @@ -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(), + ) +} diff --git a/mutation_set_pickup_point.go b/mutation_set_pickup_point.go new file mode 100644 index 0000000..ba8b8fe --- /dev/null +++ b/mutation_set_pickup_point.go @@ -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. + ) +} diff --git a/pool-server.go b/pool-server.go new file mode 100644 index 0000000..bc72f19 --- /dev/null +++ b/pool-server.go @@ -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 +} diff --git a/proto/cart_actor.pb.go b/proto/cart_actor.pb.go index f7f1a73..c3c5249 100644 --- a/proto/cart_actor.pb.go +++ b/proto/cart_actor.pb.go @@ -21,45 +21,34 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) -// MutationEnvelope carries exactly one mutation plus metadata. -// client_timestamp: -// - Optional Unix timestamp provided by the client. -// - If zero the server MAY overwrite with its local time. -type MutationEnvelope struct { - state protoimpl.MessageState `protogen:"open.v1"` - CartId string `protobuf:"bytes,1,opt,name=cart_id,json=cartId,proto3" json:"cart_id,omitempty"` - ClientTimestamp int64 `protobuf:"varint,2,opt,name=client_timestamp,json=clientTimestamp,proto3" json:"client_timestamp,omitempty"` - // Types that are valid to be assigned to Mutation: +// Shared reply for all mutation RPCs. +type CartMutationReply struct { + state protoimpl.MessageState `protogen:"open.v1"` + StatusCode int32 `protobuf:"varint,1,opt,name=status_code,json=statusCode,proto3" json:"status_code,omitempty"` // HTTP-like status (200 success, 4xx client, 5xx server) + // Types that are valid to be assigned to Result: // - // *MutationEnvelope_AddRequest - // *MutationEnvelope_AddItem - // *MutationEnvelope_RemoveItem - // *MutationEnvelope_RemoveDelivery - // *MutationEnvelope_ChangeQuantity - // *MutationEnvelope_SetDelivery - // *MutationEnvelope_SetPickupPoint - // *MutationEnvelope_CreateCheckoutOrder - // *MutationEnvelope_SetCartItems - // *MutationEnvelope_OrderCompleted - Mutation isMutationEnvelope_Mutation `protobuf_oneof:"mutation"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // *CartMutationReply_State + // *CartMutationReply_Error + Result isCartMutationReply_Result `protobuf_oneof:"result"` + ServerTimestamp int64 `protobuf:"varint,4,opt,name=server_timestamp,json=serverTimestamp,proto3" json:"server_timestamp,omitempty"` // Server-assigned Unix timestamp (optional auditing) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *MutationEnvelope) Reset() { - *x = MutationEnvelope{} +func (x *CartMutationReply) Reset() { + *x = CartMutationReply{} mi := &file_cart_actor_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *MutationEnvelope) String() string { +func (x *CartMutationReply) String() string { return protoimpl.X.MessageStringOf(x) } -func (*MutationEnvelope) ProtoMessage() {} +func (*CartMutationReply) ProtoMessage() {} -func (x *MutationEnvelope) ProtoReflect() protoreflect.Message { +func (x *CartMutationReply) ProtoReflect() protoreflect.Message { mi := &file_cart_actor_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -71,281 +60,67 @@ func (x *MutationEnvelope) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use MutationEnvelope.ProtoReflect.Descriptor instead. -func (*MutationEnvelope) Descriptor() ([]byte, []int) { +// Deprecated: Use CartMutationReply.ProtoReflect.Descriptor instead. +func (*CartMutationReply) Descriptor() ([]byte, []int) { return file_cart_actor_proto_rawDescGZIP(), []int{0} } -func (x *MutationEnvelope) GetCartId() string { - if x != nil { - return x.CartId - } - return "" -} - -func (x *MutationEnvelope) GetClientTimestamp() int64 { - if x != nil { - return x.ClientTimestamp - } - return 0 -} - -func (x *MutationEnvelope) GetMutation() isMutationEnvelope_Mutation { - if x != nil { - return x.Mutation - } - return nil -} - -func (x *MutationEnvelope) GetAddRequest() *AddRequest { - if x != nil { - if x, ok := x.Mutation.(*MutationEnvelope_AddRequest); ok { - return x.AddRequest - } - } - return nil -} - -func (x *MutationEnvelope) GetAddItem() *AddItem { - if x != nil { - if x, ok := x.Mutation.(*MutationEnvelope_AddItem); ok { - return x.AddItem - } - } - return nil -} - -func (x *MutationEnvelope) GetRemoveItem() *RemoveItem { - if x != nil { - if x, ok := x.Mutation.(*MutationEnvelope_RemoveItem); ok { - return x.RemoveItem - } - } - return nil -} - -func (x *MutationEnvelope) GetRemoveDelivery() *RemoveDelivery { - if x != nil { - if x, ok := x.Mutation.(*MutationEnvelope_RemoveDelivery); ok { - return x.RemoveDelivery - } - } - return nil -} - -func (x *MutationEnvelope) GetChangeQuantity() *ChangeQuantity { - if x != nil { - if x, ok := x.Mutation.(*MutationEnvelope_ChangeQuantity); ok { - return x.ChangeQuantity - } - } - return nil -} - -func (x *MutationEnvelope) GetSetDelivery() *SetDelivery { - if x != nil { - if x, ok := x.Mutation.(*MutationEnvelope_SetDelivery); ok { - return x.SetDelivery - } - } - return nil -} - -func (x *MutationEnvelope) GetSetPickupPoint() *SetPickupPoint { - if x != nil { - if x, ok := x.Mutation.(*MutationEnvelope_SetPickupPoint); ok { - return x.SetPickupPoint - } - } - return nil -} - -func (x *MutationEnvelope) GetCreateCheckoutOrder() *CreateCheckoutOrder { - if x != nil { - if x, ok := x.Mutation.(*MutationEnvelope_CreateCheckoutOrder); ok { - return x.CreateCheckoutOrder - } - } - return nil -} - -func (x *MutationEnvelope) GetSetCartItems() *SetCartRequest { - if x != nil { - if x, ok := x.Mutation.(*MutationEnvelope_SetCartItems); ok { - return x.SetCartItems - } - } - return nil -} - -func (x *MutationEnvelope) GetOrderCompleted() *OrderCreated { - if x != nil { - if x, ok := x.Mutation.(*MutationEnvelope_OrderCompleted); ok { - return x.OrderCompleted - } - } - return nil -} - -type isMutationEnvelope_Mutation interface { - isMutationEnvelope_Mutation() -} - -type MutationEnvelope_AddRequest struct { - AddRequest *AddRequest `protobuf:"bytes,10,opt,name=add_request,json=addRequest,proto3,oneof"` -} - -type MutationEnvelope_AddItem struct { - AddItem *AddItem `protobuf:"bytes,11,opt,name=add_item,json=addItem,proto3,oneof"` -} - -type MutationEnvelope_RemoveItem struct { - RemoveItem *RemoveItem `protobuf:"bytes,12,opt,name=remove_item,json=removeItem,proto3,oneof"` -} - -type MutationEnvelope_RemoveDelivery struct { - RemoveDelivery *RemoveDelivery `protobuf:"bytes,13,opt,name=remove_delivery,json=removeDelivery,proto3,oneof"` -} - -type MutationEnvelope_ChangeQuantity struct { - ChangeQuantity *ChangeQuantity `protobuf:"bytes,14,opt,name=change_quantity,json=changeQuantity,proto3,oneof"` -} - -type MutationEnvelope_SetDelivery struct { - SetDelivery *SetDelivery `protobuf:"bytes,15,opt,name=set_delivery,json=setDelivery,proto3,oneof"` -} - -type MutationEnvelope_SetPickupPoint struct { - SetPickupPoint *SetPickupPoint `protobuf:"bytes,16,opt,name=set_pickup_point,json=setPickupPoint,proto3,oneof"` -} - -type MutationEnvelope_CreateCheckoutOrder struct { - CreateCheckoutOrder *CreateCheckoutOrder `protobuf:"bytes,17,opt,name=create_checkout_order,json=createCheckoutOrder,proto3,oneof"` -} - -type MutationEnvelope_SetCartItems struct { - SetCartItems *SetCartRequest `protobuf:"bytes,18,opt,name=set_cart_items,json=setCartItems,proto3,oneof"` -} - -type MutationEnvelope_OrderCompleted struct { - OrderCompleted *OrderCreated `protobuf:"bytes,19,opt,name=order_completed,json=orderCompleted,proto3,oneof"` -} - -func (*MutationEnvelope_AddRequest) isMutationEnvelope_Mutation() {} - -func (*MutationEnvelope_AddItem) isMutationEnvelope_Mutation() {} - -func (*MutationEnvelope_RemoveItem) isMutationEnvelope_Mutation() {} - -func (*MutationEnvelope_RemoveDelivery) isMutationEnvelope_Mutation() {} - -func (*MutationEnvelope_ChangeQuantity) isMutationEnvelope_Mutation() {} - -func (*MutationEnvelope_SetDelivery) isMutationEnvelope_Mutation() {} - -func (*MutationEnvelope_SetPickupPoint) isMutationEnvelope_Mutation() {} - -func (*MutationEnvelope_CreateCheckoutOrder) isMutationEnvelope_Mutation() {} - -func (*MutationEnvelope_SetCartItems) isMutationEnvelope_Mutation() {} - -func (*MutationEnvelope_OrderCompleted) isMutationEnvelope_Mutation() {} - -// MutationReply returns a legacy-style status code plus a JSON payload -// holding either the updated cart state (on success) or an error string. -type MutationReply struct { - state protoimpl.MessageState `protogen:"open.v1"` - StatusCode int32 `protobuf:"varint,1,opt,name=status_code,json=statusCode,proto3" json:"status_code,omitempty"` - // Exactly one of state or error will be set. - // - // Types that are valid to be assigned to Result: - // - // *MutationReply_State - // *MutationReply_Error - Result isMutationReply_Result `protobuf_oneof:"result"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *MutationReply) Reset() { - *x = MutationReply{} - mi := &file_cart_actor_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *MutationReply) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*MutationReply) ProtoMessage() {} - -func (x *MutationReply) ProtoReflect() protoreflect.Message { - mi := &file_cart_actor_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use MutationReply.ProtoReflect.Descriptor instead. -func (*MutationReply) Descriptor() ([]byte, []int) { - return file_cart_actor_proto_rawDescGZIP(), []int{1} -} - -func (x *MutationReply) GetStatusCode() int32 { +func (x *CartMutationReply) GetStatusCode() int32 { if x != nil { return x.StatusCode } return 0 } -func (x *MutationReply) GetResult() isMutationReply_Result { +func (x *CartMutationReply) GetResult() isCartMutationReply_Result { if x != nil { return x.Result } return nil } -func (x *MutationReply) GetState() *CartState { +func (x *CartMutationReply) GetState() *CartState { if x != nil { - if x, ok := x.Result.(*MutationReply_State); ok { + if x, ok := x.Result.(*CartMutationReply_State); ok { return x.State } } return nil } -func (x *MutationReply) GetError() string { +func (x *CartMutationReply) GetError() string { if x != nil { - if x, ok := x.Result.(*MutationReply_Error); ok { + if x, ok := x.Result.(*CartMutationReply_Error); ok { return x.Error } } return "" } -type isMutationReply_Result interface { - isMutationReply_Result() +func (x *CartMutationReply) GetServerTimestamp() int64 { + if x != nil { + return x.ServerTimestamp + } + return 0 } -type MutationReply_State struct { - State *CartState `protobuf:"bytes,2,opt,name=state,proto3,oneof"` +type isCartMutationReply_Result interface { + isCartMutationReply_Result() } -type MutationReply_Error struct { - Error string `protobuf:"bytes,3,opt,name=error,proto3,oneof"` +type CartMutationReply_State struct { + State *CartState `protobuf:"bytes,2,opt,name=state,proto3,oneof"` // Updated cart state on success } -func (*MutationReply_State) isMutationReply_Result() {} +type CartMutationReply_Error struct { + Error string `protobuf:"bytes,3,opt,name=error,proto3,oneof"` // Error message on failure +} -func (*MutationReply_Error) isMutationReply_Result() {} +func (*CartMutationReply_State) isCartMutationReply_Result() {} -// StateRequest fetches current cart state without mutating. +func (*CartMutationReply_Error) isCartMutationReply_Result() {} + +// Fetch current cart state without mutation. type StateRequest struct { state protoimpl.MessageState `protogen:"open.v1"` CartId string `protobuf:"bytes,1,opt,name=cart_id,json=cartId,proto3" json:"cart_id,omitempty"` @@ -355,7 +130,7 @@ type StateRequest struct { func (x *StateRequest) Reset() { *x = StateRequest{} - mi := &file_cart_actor_proto_msgTypes[2] + mi := &file_cart_actor_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -367,7 +142,7 @@ func (x *StateRequest) String() string { func (*StateRequest) ProtoMessage() {} func (x *StateRequest) ProtoReflect() protoreflect.Message { - mi := &file_cart_actor_proto_msgTypes[2] + mi := &file_cart_actor_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -380,7 +155,7 @@ func (x *StateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StateRequest.ProtoReflect.Descriptor instead. func (*StateRequest) Descriptor() ([]byte, []int) { - return file_cart_actor_proto_rawDescGZIP(), []int{2} + return file_cart_actor_proto_rawDescGZIP(), []int{1} } func (x *StateRequest) GetCartId() string { @@ -390,9 +165,698 @@ func (x *StateRequest) GetCartId() string { return "" } +type StateReply struct { + state protoimpl.MessageState `protogen:"open.v1"` + StatusCode int32 `protobuf:"varint,1,opt,name=status_code,json=statusCode,proto3" json:"status_code,omitempty"` + // Types that are valid to be assigned to Result: + // + // *StateReply_State + // *StateReply_Error + Result isStateReply_Result `protobuf_oneof:"result"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StateReply) Reset() { + *x = StateReply{} + mi := &file_cart_actor_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StateReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StateReply) ProtoMessage() {} + +func (x *StateReply) ProtoReflect() protoreflect.Message { + mi := &file_cart_actor_proto_msgTypes[2] + 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 StateReply.ProtoReflect.Descriptor instead. +func (*StateReply) Descriptor() ([]byte, []int) { + return file_cart_actor_proto_rawDescGZIP(), []int{2} +} + +func (x *StateReply) GetStatusCode() int32 { + if x != nil { + return x.StatusCode + } + return 0 +} + +func (x *StateReply) GetResult() isStateReply_Result { + if x != nil { + return x.Result + } + return nil +} + +func (x *StateReply) GetState() *CartState { + if x != nil { + if x, ok := x.Result.(*StateReply_State); ok { + return x.State + } + } + return nil +} + +func (x *StateReply) GetError() string { + if x != nil { + if x, ok := x.Result.(*StateReply_Error); ok { + return x.Error + } + } + return "" +} + +type isStateReply_Result interface { + isStateReply_Result() +} + +type StateReply_State struct { + State *CartState `protobuf:"bytes,2,opt,name=state,proto3,oneof"` +} + +type StateReply_Error struct { + Error string `protobuf:"bytes,3,opt,name=error,proto3,oneof"` +} + +func (*StateReply_State) isStateReply_Result() {} + +func (*StateReply_Error) isStateReply_Result() {} + +type AddRequestRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + CartId string `protobuf:"bytes,1,opt,name=cart_id,json=cartId,proto3" json:"cart_id,omitempty"` + ClientTimestamp int64 `protobuf:"varint,2,opt,name=client_timestamp,json=clientTimestamp,proto3" json:"client_timestamp,omitempty"` + Payload *AddRequest `protobuf:"bytes,10,opt,name=payload,proto3" json:"payload,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddRequestRequest) Reset() { + *x = AddRequestRequest{} + mi := &file_cart_actor_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddRequestRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddRequestRequest) ProtoMessage() {} + +func (x *AddRequestRequest) ProtoReflect() protoreflect.Message { + mi := &file_cart_actor_proto_msgTypes[3] + 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 AddRequestRequest.ProtoReflect.Descriptor instead. +func (*AddRequestRequest) Descriptor() ([]byte, []int) { + return file_cart_actor_proto_rawDescGZIP(), []int{3} +} + +func (x *AddRequestRequest) GetCartId() string { + if x != nil { + return x.CartId + } + return "" +} + +func (x *AddRequestRequest) GetClientTimestamp() int64 { + if x != nil { + return x.ClientTimestamp + } + return 0 +} + +func (x *AddRequestRequest) GetPayload() *AddRequest { + if x != nil { + return x.Payload + } + return nil +} + +type AddItemRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + CartId string `protobuf:"bytes,1,opt,name=cart_id,json=cartId,proto3" json:"cart_id,omitempty"` + ClientTimestamp int64 `protobuf:"varint,2,opt,name=client_timestamp,json=clientTimestamp,proto3" json:"client_timestamp,omitempty"` + Payload *AddItem `protobuf:"bytes,10,opt,name=payload,proto3" json:"payload,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddItemRequest) Reset() { + *x = AddItemRequest{} + mi := &file_cart_actor_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddItemRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddItemRequest) ProtoMessage() {} + +func (x *AddItemRequest) ProtoReflect() protoreflect.Message { + mi := &file_cart_actor_proto_msgTypes[4] + 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 AddItemRequest.ProtoReflect.Descriptor instead. +func (*AddItemRequest) Descriptor() ([]byte, []int) { + return file_cart_actor_proto_rawDescGZIP(), []int{4} +} + +func (x *AddItemRequest) GetCartId() string { + if x != nil { + return x.CartId + } + return "" +} + +func (x *AddItemRequest) GetClientTimestamp() int64 { + if x != nil { + return x.ClientTimestamp + } + return 0 +} + +func (x *AddItemRequest) GetPayload() *AddItem { + if x != nil { + return x.Payload + } + return nil +} + +type RemoveItemRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + CartId string `protobuf:"bytes,1,opt,name=cart_id,json=cartId,proto3" json:"cart_id,omitempty"` + ClientTimestamp int64 `protobuf:"varint,2,opt,name=client_timestamp,json=clientTimestamp,proto3" json:"client_timestamp,omitempty"` + Payload *RemoveItem `protobuf:"bytes,10,opt,name=payload,proto3" json:"payload,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveItemRequest) Reset() { + *x = RemoveItemRequest{} + mi := &file_cart_actor_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveItemRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveItemRequest) ProtoMessage() {} + +func (x *RemoveItemRequest) ProtoReflect() protoreflect.Message { + mi := &file_cart_actor_proto_msgTypes[5] + 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 RemoveItemRequest.ProtoReflect.Descriptor instead. +func (*RemoveItemRequest) Descriptor() ([]byte, []int) { + return file_cart_actor_proto_rawDescGZIP(), []int{5} +} + +func (x *RemoveItemRequest) GetCartId() string { + if x != nil { + return x.CartId + } + return "" +} + +func (x *RemoveItemRequest) GetClientTimestamp() int64 { + if x != nil { + return x.ClientTimestamp + } + return 0 +} + +func (x *RemoveItemRequest) GetPayload() *RemoveItem { + if x != nil { + return x.Payload + } + return nil +} + +type RemoveDeliveryRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + CartId string `protobuf:"bytes,1,opt,name=cart_id,json=cartId,proto3" json:"cart_id,omitempty"` + ClientTimestamp int64 `protobuf:"varint,2,opt,name=client_timestamp,json=clientTimestamp,proto3" json:"client_timestamp,omitempty"` + Payload *RemoveDelivery `protobuf:"bytes,10,opt,name=payload,proto3" json:"payload,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveDeliveryRequest) Reset() { + *x = RemoveDeliveryRequest{} + mi := &file_cart_actor_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveDeliveryRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveDeliveryRequest) ProtoMessage() {} + +func (x *RemoveDeliveryRequest) ProtoReflect() protoreflect.Message { + mi := &file_cart_actor_proto_msgTypes[6] + 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 RemoveDeliveryRequest.ProtoReflect.Descriptor instead. +func (*RemoveDeliveryRequest) Descriptor() ([]byte, []int) { + return file_cart_actor_proto_rawDescGZIP(), []int{6} +} + +func (x *RemoveDeliveryRequest) GetCartId() string { + if x != nil { + return x.CartId + } + return "" +} + +func (x *RemoveDeliveryRequest) GetClientTimestamp() int64 { + if x != nil { + return x.ClientTimestamp + } + return 0 +} + +func (x *RemoveDeliveryRequest) GetPayload() *RemoveDelivery { + if x != nil { + return x.Payload + } + return nil +} + +type ChangeQuantityRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + CartId string `protobuf:"bytes,1,opt,name=cart_id,json=cartId,proto3" json:"cart_id,omitempty"` + ClientTimestamp int64 `protobuf:"varint,2,opt,name=client_timestamp,json=clientTimestamp,proto3" json:"client_timestamp,omitempty"` + Payload *ChangeQuantity `protobuf:"bytes,10,opt,name=payload,proto3" json:"payload,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChangeQuantityRequest) Reset() { + *x = ChangeQuantityRequest{} + mi := &file_cart_actor_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChangeQuantityRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChangeQuantityRequest) ProtoMessage() {} + +func (x *ChangeQuantityRequest) ProtoReflect() protoreflect.Message { + mi := &file_cart_actor_proto_msgTypes[7] + 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 ChangeQuantityRequest.ProtoReflect.Descriptor instead. +func (*ChangeQuantityRequest) Descriptor() ([]byte, []int) { + return file_cart_actor_proto_rawDescGZIP(), []int{7} +} + +func (x *ChangeQuantityRequest) GetCartId() string { + if x != nil { + return x.CartId + } + return "" +} + +func (x *ChangeQuantityRequest) GetClientTimestamp() int64 { + if x != nil { + return x.ClientTimestamp + } + return 0 +} + +func (x *ChangeQuantityRequest) GetPayload() *ChangeQuantity { + if x != nil { + return x.Payload + } + return nil +} + +type SetDeliveryRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + CartId string `protobuf:"bytes,1,opt,name=cart_id,json=cartId,proto3" json:"cart_id,omitempty"` + ClientTimestamp int64 `protobuf:"varint,2,opt,name=client_timestamp,json=clientTimestamp,proto3" json:"client_timestamp,omitempty"` + Payload *SetDelivery `protobuf:"bytes,10,opt,name=payload,proto3" json:"payload,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetDeliveryRequest) Reset() { + *x = SetDeliveryRequest{} + mi := &file_cart_actor_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetDeliveryRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetDeliveryRequest) ProtoMessage() {} + +func (x *SetDeliveryRequest) ProtoReflect() protoreflect.Message { + mi := &file_cart_actor_proto_msgTypes[8] + 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 SetDeliveryRequest.ProtoReflect.Descriptor instead. +func (*SetDeliveryRequest) Descriptor() ([]byte, []int) { + return file_cart_actor_proto_rawDescGZIP(), []int{8} +} + +func (x *SetDeliveryRequest) GetCartId() string { + if x != nil { + return x.CartId + } + return "" +} + +func (x *SetDeliveryRequest) GetClientTimestamp() int64 { + if x != nil { + return x.ClientTimestamp + } + return 0 +} + +func (x *SetDeliveryRequest) GetPayload() *SetDelivery { + if x != nil { + return x.Payload + } + return nil +} + +type SetPickupPointRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + CartId string `protobuf:"bytes,1,opt,name=cart_id,json=cartId,proto3" json:"cart_id,omitempty"` + ClientTimestamp int64 `protobuf:"varint,2,opt,name=client_timestamp,json=clientTimestamp,proto3" json:"client_timestamp,omitempty"` + Payload *SetPickupPoint `protobuf:"bytes,10,opt,name=payload,proto3" json:"payload,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetPickupPointRequest) Reset() { + *x = SetPickupPointRequest{} + mi := &file_cart_actor_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetPickupPointRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetPickupPointRequest) ProtoMessage() {} + +func (x *SetPickupPointRequest) ProtoReflect() protoreflect.Message { + mi := &file_cart_actor_proto_msgTypes[9] + 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 SetPickupPointRequest.ProtoReflect.Descriptor instead. +func (*SetPickupPointRequest) Descriptor() ([]byte, []int) { + return file_cart_actor_proto_rawDescGZIP(), []int{9} +} + +func (x *SetPickupPointRequest) GetCartId() string { + if x != nil { + return x.CartId + } + return "" +} + +func (x *SetPickupPointRequest) GetClientTimestamp() int64 { + if x != nil { + return x.ClientTimestamp + } + return 0 +} + +func (x *SetPickupPointRequest) GetPayload() *SetPickupPoint { + if x != nil { + return x.Payload + } + return nil +} + +type CreateCheckoutOrderRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + CartId string `protobuf:"bytes,1,opt,name=cart_id,json=cartId,proto3" json:"cart_id,omitempty"` + ClientTimestamp int64 `protobuf:"varint,2,opt,name=client_timestamp,json=clientTimestamp,proto3" json:"client_timestamp,omitempty"` + Payload *CreateCheckoutOrder `protobuf:"bytes,10,opt,name=payload,proto3" json:"payload,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateCheckoutOrderRequest) Reset() { + *x = CreateCheckoutOrderRequest{} + mi := &file_cart_actor_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateCheckoutOrderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateCheckoutOrderRequest) ProtoMessage() {} + +func (x *CreateCheckoutOrderRequest) ProtoReflect() protoreflect.Message { + mi := &file_cart_actor_proto_msgTypes[10] + 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 CreateCheckoutOrderRequest.ProtoReflect.Descriptor instead. +func (*CreateCheckoutOrderRequest) Descriptor() ([]byte, []int) { + return file_cart_actor_proto_rawDescGZIP(), []int{10} +} + +func (x *CreateCheckoutOrderRequest) GetCartId() string { + if x != nil { + return x.CartId + } + return "" +} + +func (x *CreateCheckoutOrderRequest) GetClientTimestamp() int64 { + if x != nil { + return x.ClientTimestamp + } + return 0 +} + +func (x *CreateCheckoutOrderRequest) GetPayload() *CreateCheckoutOrder { + if x != nil { + return x.Payload + } + return nil +} + +type SetCartItemsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + CartId string `protobuf:"bytes,1,opt,name=cart_id,json=cartId,proto3" json:"cart_id,omitempty"` + ClientTimestamp int64 `protobuf:"varint,2,opt,name=client_timestamp,json=clientTimestamp,proto3" json:"client_timestamp,omitempty"` + Payload *SetCartRequest `protobuf:"bytes,10,opt,name=payload,proto3" json:"payload,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetCartItemsRequest) Reset() { + *x = SetCartItemsRequest{} + mi := &file_cart_actor_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetCartItemsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetCartItemsRequest) ProtoMessage() {} + +func (x *SetCartItemsRequest) ProtoReflect() protoreflect.Message { + mi := &file_cart_actor_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 SetCartItemsRequest.ProtoReflect.Descriptor instead. +func (*SetCartItemsRequest) Descriptor() ([]byte, []int) { + return file_cart_actor_proto_rawDescGZIP(), []int{11} +} + +func (x *SetCartItemsRequest) GetCartId() string { + if x != nil { + return x.CartId + } + return "" +} + +func (x *SetCartItemsRequest) GetClientTimestamp() int64 { + if x != nil { + return x.ClientTimestamp + } + return 0 +} + +func (x *SetCartItemsRequest) GetPayload() *SetCartRequest { + if x != nil { + return x.Payload + } + return nil +} + +type OrderCompletedRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + CartId string `protobuf:"bytes,1,opt,name=cart_id,json=cartId,proto3" json:"cart_id,omitempty"` + ClientTimestamp int64 `protobuf:"varint,2,opt,name=client_timestamp,json=clientTimestamp,proto3" json:"client_timestamp,omitempty"` + Payload *OrderCreated `protobuf:"bytes,10,opt,name=payload,proto3" json:"payload,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OrderCompletedRequest) Reset() { + *x = OrderCompletedRequest{} + mi := &file_cart_actor_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OrderCompletedRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OrderCompletedRequest) ProtoMessage() {} + +func (x *OrderCompletedRequest) ProtoReflect() protoreflect.Message { + mi := &file_cart_actor_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 OrderCompletedRequest.ProtoReflect.Descriptor instead. +func (*OrderCompletedRequest) Descriptor() ([]byte, []int) { + return file_cart_actor_proto_rawDescGZIP(), []int{12} +} + +func (x *OrderCompletedRequest) GetCartId() string { + if x != nil { + return x.CartId + } + return "" +} + +func (x *OrderCompletedRequest) GetClientTimestamp() int64 { + if x != nil { + return x.ClientTimestamp + } + return 0 +} + +func (x *OrderCompletedRequest) GetPayload() *OrderCreated { + if x != nil { + return x.Payload + } + return nil +} + // ----------------------------------------------------------------------------- -// CartState represents the full cart snapshot returned by state/mutation replies. -// Replaces the previous raw JSON payload. +// Cart state snapshot (unchanged from v1 except envelope removal context) // ----------------------------------------------------------------------------- type CartState struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -411,7 +875,7 @@ type CartState struct { func (x *CartState) Reset() { *x = CartState{} - mi := &file_cart_actor_proto_msgTypes[3] + mi := &file_cart_actor_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -423,7 +887,7 @@ func (x *CartState) String() string { func (*CartState) ProtoMessage() {} func (x *CartState) ProtoReflect() protoreflect.Message { - mi := &file_cart_actor_proto_msgTypes[3] + mi := &file_cart_actor_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -436,7 +900,7 @@ func (x *CartState) ProtoReflect() protoreflect.Message { // Deprecated: Use CartState.ProtoReflect.Descriptor instead. func (*CartState) Descriptor() ([]byte, []int) { - return file_cart_actor_proto_rawDescGZIP(), []int{3} + return file_cart_actor_proto_rawDescGZIP(), []int{13} } func (x *CartState) GetCartId() string { @@ -502,7 +966,6 @@ func (x *CartState) GetPaymentStatus() string { return "" } -// Lightweight representation of an item in the cart type CartItemState struct { state protoimpl.MessageState `protogen:"open.v1"` Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` @@ -536,7 +999,7 @@ type CartItemState struct { func (x *CartItemState) Reset() { *x = CartItemState{} - mi := &file_cart_actor_proto_msgTypes[4] + mi := &file_cart_actor_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -548,7 +1011,7 @@ func (x *CartItemState) String() string { func (*CartItemState) ProtoMessage() {} func (x *CartItemState) ProtoReflect() protoreflect.Message { - mi := &file_cart_actor_proto_msgTypes[4] + mi := &file_cart_actor_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -561,7 +1024,7 @@ func (x *CartItemState) ProtoReflect() protoreflect.Message { // Deprecated: Use CartItemState.ProtoReflect.Descriptor instead. func (*CartItemState) Descriptor() ([]byte, []int) { - return file_cart_actor_proto_rawDescGZIP(), []int{4} + return file_cart_actor_proto_rawDescGZIP(), []int{14} } func (x *CartItemState) GetId() int64 { @@ -739,21 +1202,20 @@ func (x *CartItemState) GetStock() int32 { return 0 } -// Delivery / shipping entry type DeliveryState struct { state protoimpl.MessageState `protogen:"open.v1"` Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` Provider string `protobuf:"bytes,2,opt,name=provider,proto3" json:"provider,omitempty"` Price int64 `protobuf:"varint,3,opt,name=price,proto3" json:"price,omitempty"` ItemIds []int64 `protobuf:"varint,4,rep,packed,name=item_ids,json=itemIds,proto3" json:"item_ids,omitempty"` - PickupPoint *PickupPoint `protobuf:"bytes,5,opt,name=pickup_point,json=pickupPoint,proto3" json:"pickup_point,omitempty"` + PickupPoint *PickupPoint `protobuf:"bytes,5,opt,name=pickup_point,json=pickupPoint,proto3" json:"pickup_point,omitempty"` // Defined in messages.proto unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DeliveryState) Reset() { *x = DeliveryState{} - mi := &file_cart_actor_proto_msgTypes[5] + mi := &file_cart_actor_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -765,7 +1227,7 @@ func (x *DeliveryState) String() string { func (*DeliveryState) ProtoMessage() {} func (x *DeliveryState) ProtoReflect() protoreflect.Message { - mi := &file_cart_actor_proto_msgTypes[5] + mi := &file_cart_actor_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -778,7 +1240,7 @@ func (x *DeliveryState) ProtoReflect() protoreflect.Message { // Deprecated: Use DeliveryState.ProtoReflect.Descriptor instead. func (*DeliveryState) Descriptor() ([]byte, []int) { - return file_cart_actor_proto_rawDescGZIP(), []int{5} + return file_cart_actor_proto_rawDescGZIP(), []int{15} } func (x *DeliveryState) GetId() int64 { @@ -816,128 +1278,77 @@ func (x *DeliveryState) GetPickupPoint() *PickupPoint { return nil } -// StateReply mirrors MutationReply for consistency. -type StateReply struct { - state protoimpl.MessageState `protogen:"open.v1"` - StatusCode int32 `protobuf:"varint,1,opt,name=status_code,json=statusCode,proto3" json:"status_code,omitempty"` - // Types that are valid to be assigned to Result: - // - // *StateReply_State - // *StateReply_Error - Result isStateReply_Result `protobuf_oneof:"result"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StateReply) Reset() { - *x = StateReply{} - mi := &file_cart_actor_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StateReply) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StateReply) ProtoMessage() {} - -func (x *StateReply) ProtoReflect() protoreflect.Message { - mi := &file_cart_actor_proto_msgTypes[6] - 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 StateReply.ProtoReflect.Descriptor instead. -func (*StateReply) Descriptor() ([]byte, []int) { - return file_cart_actor_proto_rawDescGZIP(), []int{6} -} - -func (x *StateReply) GetStatusCode() int32 { - if x != nil { - return x.StatusCode - } - return 0 -} - -func (x *StateReply) GetResult() isStateReply_Result { - if x != nil { - return x.Result - } - return nil -} - -func (x *StateReply) GetState() *CartState { - if x != nil { - if x, ok := x.Result.(*StateReply_State); ok { - return x.State - } - } - return nil -} - -func (x *StateReply) GetError() string { - if x != nil { - if x, ok := x.Result.(*StateReply_Error); ok { - return x.Error - } - } - return "" -} - -type isStateReply_Result interface { - isStateReply_Result() -} - -type StateReply_State struct { - State *CartState `protobuf:"bytes,2,opt,name=state,proto3,oneof"` -} - -type StateReply_Error struct { - Error string `protobuf:"bytes,3,opt,name=error,proto3,oneof"` -} - -func (*StateReply_State) isStateReply_Result() {} - -func (*StateReply_Error) isStateReply_Result() {} - var File_cart_actor_proto protoreflect.FileDescriptor const file_cart_actor_proto_rawDesc = "" + "\n" + - "\x10cart_actor.proto\x12\bmessages\x1a\x0emessages.proto\"\xea\x05\n" + - "\x10MutationEnvelope\x12\x17\n" + - "\acart_id\x18\x01 \x01(\tR\x06cartId\x12)\n" + - "\x10client_timestamp\x18\x02 \x01(\x03R\x0fclientTimestamp\x127\n" + - "\vadd_request\x18\n" + - " \x01(\v2\x14.messages.AddRequestH\x00R\n" + - "addRequest\x12.\n" + - "\badd_item\x18\v \x01(\v2\x11.messages.AddItemH\x00R\aaddItem\x127\n" + - "\vremove_item\x18\f \x01(\v2\x14.messages.RemoveItemH\x00R\n" + - "removeItem\x12C\n" + - "\x0fremove_delivery\x18\r \x01(\v2\x18.messages.RemoveDeliveryH\x00R\x0eremoveDelivery\x12C\n" + - "\x0fchange_quantity\x18\x0e \x01(\v2\x18.messages.ChangeQuantityH\x00R\x0echangeQuantity\x12:\n" + - "\fset_delivery\x18\x0f \x01(\v2\x15.messages.SetDeliveryH\x00R\vsetDelivery\x12D\n" + - "\x10set_pickup_point\x18\x10 \x01(\v2\x18.messages.SetPickupPointH\x00R\x0esetPickupPoint\x12S\n" + - "\x15create_checkout_order\x18\x11 \x01(\v2\x1d.messages.CreateCheckoutOrderH\x00R\x13createCheckoutOrder\x12@\n" + - "\x0eset_cart_items\x18\x12 \x01(\v2\x18.messages.SetCartRequestH\x00R\fsetCartItems\x12A\n" + - "\x0forder_completed\x18\x13 \x01(\v2\x16.messages.OrderCreatedH\x00R\x0eorderCompletedB\n" + + "\x10cart_actor.proto\x12\bmessages\x1a\x0emessages.proto\"\xae\x01\n" + + "\x11CartMutationReply\x12\x1f\n" + + "\vstatus_code\x18\x01 \x01(\x05R\n" + + "statusCode\x12+\n" + + "\x05state\x18\x02 \x01(\v2\x13.messages.CartStateH\x00R\x05state\x12\x16\n" + + "\x05error\x18\x03 \x01(\tH\x00R\x05error\x12)\n" + + "\x10server_timestamp\x18\x04 \x01(\x03R\x0fserverTimestampB\b\n" + + "\x06result\"'\n" + + "\fStateRequest\x12\x17\n" + + "\acart_id\x18\x01 \x01(\tR\x06cartId\"|\n" + "\n" + - "\bmutation\"\x7f\n" + - "\rMutationReply\x12\x1f\n" + + "StateReply\x12\x1f\n" + "\vstatus_code\x18\x01 \x01(\x05R\n" + "statusCode\x12+\n" + "\x05state\x18\x02 \x01(\v2\x13.messages.CartStateH\x00R\x05state\x12\x16\n" + "\x05error\x18\x03 \x01(\tH\x00R\x05errorB\b\n" + - "\x06result\"'\n" + - "\fStateRequest\x12\x17\n" + - "\acart_id\x18\x01 \x01(\tR\x06cartId\"\xf1\x02\n" + + "\x06result\"\x87\x01\n" + + "\x11AddRequestRequest\x12\x17\n" + + "\acart_id\x18\x01 \x01(\tR\x06cartId\x12)\n" + + "\x10client_timestamp\x18\x02 \x01(\x03R\x0fclientTimestamp\x12.\n" + + "\apayload\x18\n" + + " \x01(\v2\x14.messages.AddRequestR\apayload\"\x81\x01\n" + + "\x0eAddItemRequest\x12\x17\n" + + "\acart_id\x18\x01 \x01(\tR\x06cartId\x12)\n" + + "\x10client_timestamp\x18\x02 \x01(\x03R\x0fclientTimestamp\x12+\n" + + "\apayload\x18\n" + + " \x01(\v2\x11.messages.AddItemR\apayload\"\x87\x01\n" + + "\x11RemoveItemRequest\x12\x17\n" + + "\acart_id\x18\x01 \x01(\tR\x06cartId\x12)\n" + + "\x10client_timestamp\x18\x02 \x01(\x03R\x0fclientTimestamp\x12.\n" + + "\apayload\x18\n" + + " \x01(\v2\x14.messages.RemoveItemR\apayload\"\x8f\x01\n" + + "\x15RemoveDeliveryRequest\x12\x17\n" + + "\acart_id\x18\x01 \x01(\tR\x06cartId\x12)\n" + + "\x10client_timestamp\x18\x02 \x01(\x03R\x0fclientTimestamp\x122\n" + + "\apayload\x18\n" + + " \x01(\v2\x18.messages.RemoveDeliveryR\apayload\"\x8f\x01\n" + + "\x15ChangeQuantityRequest\x12\x17\n" + + "\acart_id\x18\x01 \x01(\tR\x06cartId\x12)\n" + + "\x10client_timestamp\x18\x02 \x01(\x03R\x0fclientTimestamp\x122\n" + + "\apayload\x18\n" + + " \x01(\v2\x18.messages.ChangeQuantityR\apayload\"\x89\x01\n" + + "\x12SetDeliveryRequest\x12\x17\n" + + "\acart_id\x18\x01 \x01(\tR\x06cartId\x12)\n" + + "\x10client_timestamp\x18\x02 \x01(\x03R\x0fclientTimestamp\x12/\n" + + "\apayload\x18\n" + + " \x01(\v2\x15.messages.SetDeliveryR\apayload\"\x8f\x01\n" + + "\x15SetPickupPointRequest\x12\x17\n" + + "\acart_id\x18\x01 \x01(\tR\x06cartId\x12)\n" + + "\x10client_timestamp\x18\x02 \x01(\x03R\x0fclientTimestamp\x122\n" + + "\apayload\x18\n" + + " \x01(\v2\x18.messages.SetPickupPointR\apayload\"\x99\x01\n" + + "\x1aCreateCheckoutOrderRequest\x12\x17\n" + + "\acart_id\x18\x01 \x01(\tR\x06cartId\x12)\n" + + "\x10client_timestamp\x18\x02 \x01(\x03R\x0fclientTimestamp\x127\n" + + "\apayload\x18\n" + + " \x01(\v2\x1d.messages.CreateCheckoutOrderR\apayload\"\x8d\x01\n" + + "\x13SetCartItemsRequest\x12\x17\n" + + "\acart_id\x18\x01 \x01(\tR\x06cartId\x12)\n" + + "\x10client_timestamp\x18\x02 \x01(\x03R\x0fclientTimestamp\x122\n" + + "\apayload\x18\n" + + " \x01(\v2\x18.messages.SetCartRequestR\apayload\"\x8d\x01\n" + + "\x15OrderCompletedRequest\x12\x17\n" + + "\acart_id\x18\x01 \x01(\tR\x06cartId\x12)\n" + + "\x10client_timestamp\x18\x02 \x01(\x03R\x0fclientTimestamp\x120\n" + + "\apayload\x18\n" + + " \x01(\v2\x16.messages.OrderCreatedR\apayload\"\xf1\x02\n" + "\tCartState\x12\x17\n" + "\acart_id\x18\x01 \x01(\tR\x06cartId\x12-\n" + "\x05items\x18\x02 \x03(\v2\x17.messages.CartItemStateR\x05items\x12\x1f\n" + @@ -988,16 +1399,19 @@ const file_cart_actor_proto_rawDesc = "" + "\bprovider\x18\x02 \x01(\tR\bprovider\x12\x14\n" + "\x05price\x18\x03 \x01(\x03R\x05price\x12\x19\n" + "\bitem_ids\x18\x04 \x03(\x03R\aitemIds\x128\n" + - "\fpickup_point\x18\x05 \x01(\v2\x15.messages.PickupPointR\vpickupPoint\"|\n" + + "\fpickup_point\x18\x05 \x01(\v2\x15.messages.PickupPointR\vpickupPoint2\xed\x05\n" + + "\tCartActor\x12F\n" + "\n" + - "StateReply\x12\x1f\n" + - "\vstatus_code\x18\x01 \x01(\x05R\n" + - "statusCode\x12+\n" + - "\x05state\x18\x02 \x01(\v2\x13.messages.CartStateH\x00R\x05state\x12\x16\n" + - "\x05error\x18\x03 \x01(\tH\x00R\x05errorB\b\n" + - "\x06result2\x84\x01\n" + - "\tCartActor\x12=\n" + - "\x06Mutate\x12\x1a.messages.MutationEnvelope\x1a\x17.messages.MutationReply\x128\n" + + "AddRequest\x12\x1b.messages.AddRequestRequest\x1a\x1b.messages.CartMutationReply\x12@\n" + + "\aAddItem\x12\x18.messages.AddItemRequest\x1a\x1b.messages.CartMutationReply\x12F\n" + + "\n" + + "RemoveItem\x12\x1b.messages.RemoveItemRequest\x1a\x1b.messages.CartMutationReply\x12N\n" + + "\x0eRemoveDelivery\x12\x1f.messages.RemoveDeliveryRequest\x1a\x1b.messages.CartMutationReply\x12N\n" + + "\x0eChangeQuantity\x12\x1f.messages.ChangeQuantityRequest\x1a\x1b.messages.CartMutationReply\x12H\n" + + "\vSetDelivery\x12\x1c.messages.SetDeliveryRequest\x1a\x1b.messages.CartMutationReply\x12N\n" + + "\x0eSetPickupPoint\x12\x1f.messages.SetPickupPointRequest\x1a\x1b.messages.CartMutationReply\x12J\n" + + "\fSetCartItems\x12\x1d.messages.SetCartItemsRequest\x1a\x1b.messages.CartMutationReply\x12N\n" + + "\x0eOrderCompleted\x12\x1f.messages.OrderCompletedRequest\x1a\x1b.messages.CartMutationReply\x128\n" + "\bGetState\x12\x16.messages.StateRequest\x1a\x14.messages.StateReplyB.Z,git.tornberg.me/go-cart-actor/proto;messagesb\x06proto3" var ( @@ -1012,49 +1426,74 @@ func file_cart_actor_proto_rawDescGZIP() []byte { return file_cart_actor_proto_rawDescData } -var file_cart_actor_proto_msgTypes = make([]protoimpl.MessageInfo, 7) +var file_cart_actor_proto_msgTypes = make([]protoimpl.MessageInfo, 16) var file_cart_actor_proto_goTypes = []any{ - (*MutationEnvelope)(nil), // 0: messages.MutationEnvelope - (*MutationReply)(nil), // 1: messages.MutationReply - (*StateRequest)(nil), // 2: messages.StateRequest - (*CartState)(nil), // 3: messages.CartState - (*CartItemState)(nil), // 4: messages.CartItemState - (*DeliveryState)(nil), // 5: messages.DeliveryState - (*StateReply)(nil), // 6: messages.StateReply - (*AddRequest)(nil), // 7: messages.AddRequest - (*AddItem)(nil), // 8: messages.AddItem - (*RemoveItem)(nil), // 9: messages.RemoveItem - (*RemoveDelivery)(nil), // 10: messages.RemoveDelivery - (*ChangeQuantity)(nil), // 11: messages.ChangeQuantity - (*SetDelivery)(nil), // 12: messages.SetDelivery - (*SetPickupPoint)(nil), // 13: messages.SetPickupPoint - (*CreateCheckoutOrder)(nil), // 14: messages.CreateCheckoutOrder - (*SetCartRequest)(nil), // 15: messages.SetCartRequest - (*OrderCreated)(nil), // 16: messages.OrderCreated - (*PickupPoint)(nil), // 17: messages.PickupPoint + (*CartMutationReply)(nil), // 0: messages.CartMutationReply + (*StateRequest)(nil), // 1: messages.StateRequest + (*StateReply)(nil), // 2: messages.StateReply + (*AddRequestRequest)(nil), // 3: messages.AddRequestRequest + (*AddItemRequest)(nil), // 4: messages.AddItemRequest + (*RemoveItemRequest)(nil), // 5: messages.RemoveItemRequest + (*RemoveDeliveryRequest)(nil), // 6: messages.RemoveDeliveryRequest + (*ChangeQuantityRequest)(nil), // 7: messages.ChangeQuantityRequest + (*SetDeliveryRequest)(nil), // 8: messages.SetDeliveryRequest + (*SetPickupPointRequest)(nil), // 9: messages.SetPickupPointRequest + (*CreateCheckoutOrderRequest)(nil), // 10: messages.CreateCheckoutOrderRequest + (*SetCartItemsRequest)(nil), // 11: messages.SetCartItemsRequest + (*OrderCompletedRequest)(nil), // 12: messages.OrderCompletedRequest + (*CartState)(nil), // 13: messages.CartState + (*CartItemState)(nil), // 14: messages.CartItemState + (*DeliveryState)(nil), // 15: messages.DeliveryState + (*AddRequest)(nil), // 16: messages.AddRequest + (*AddItem)(nil), // 17: messages.AddItem + (*RemoveItem)(nil), // 18: messages.RemoveItem + (*RemoveDelivery)(nil), // 19: messages.RemoveDelivery + (*ChangeQuantity)(nil), // 20: messages.ChangeQuantity + (*SetDelivery)(nil), // 21: messages.SetDelivery + (*SetPickupPoint)(nil), // 22: messages.SetPickupPoint + (*CreateCheckoutOrder)(nil), // 23: messages.CreateCheckoutOrder + (*SetCartRequest)(nil), // 24: messages.SetCartRequest + (*OrderCreated)(nil), // 25: messages.OrderCreated + (*PickupPoint)(nil), // 26: messages.PickupPoint } var file_cart_actor_proto_depIdxs = []int32{ - 7, // 0: messages.MutationEnvelope.add_request:type_name -> messages.AddRequest - 8, // 1: messages.MutationEnvelope.add_item:type_name -> messages.AddItem - 9, // 2: messages.MutationEnvelope.remove_item:type_name -> messages.RemoveItem - 10, // 3: messages.MutationEnvelope.remove_delivery:type_name -> messages.RemoveDelivery - 11, // 4: messages.MutationEnvelope.change_quantity:type_name -> messages.ChangeQuantity - 12, // 5: messages.MutationEnvelope.set_delivery:type_name -> messages.SetDelivery - 13, // 6: messages.MutationEnvelope.set_pickup_point:type_name -> messages.SetPickupPoint - 14, // 7: messages.MutationEnvelope.create_checkout_order:type_name -> messages.CreateCheckoutOrder - 15, // 8: messages.MutationEnvelope.set_cart_items:type_name -> messages.SetCartRequest - 16, // 9: messages.MutationEnvelope.order_completed:type_name -> messages.OrderCreated - 3, // 10: messages.MutationReply.state:type_name -> messages.CartState - 4, // 11: messages.CartState.items:type_name -> messages.CartItemState - 5, // 12: messages.CartState.deliveries:type_name -> messages.DeliveryState - 17, // 13: messages.DeliveryState.pickup_point:type_name -> messages.PickupPoint - 3, // 14: messages.StateReply.state:type_name -> messages.CartState - 0, // 15: messages.CartActor.Mutate:input_type -> messages.MutationEnvelope - 2, // 16: messages.CartActor.GetState:input_type -> messages.StateRequest - 1, // 17: messages.CartActor.Mutate:output_type -> messages.MutationReply - 6, // 18: messages.CartActor.GetState:output_type -> messages.StateReply - 17, // [17:19] is the sub-list for method output_type - 15, // [15:17] is the sub-list for method input_type + 13, // 0: messages.CartMutationReply.state:type_name -> messages.CartState + 13, // 1: messages.StateReply.state:type_name -> messages.CartState + 16, // 2: messages.AddRequestRequest.payload:type_name -> messages.AddRequest + 17, // 3: messages.AddItemRequest.payload:type_name -> messages.AddItem + 18, // 4: messages.RemoveItemRequest.payload:type_name -> messages.RemoveItem + 19, // 5: messages.RemoveDeliveryRequest.payload:type_name -> messages.RemoveDelivery + 20, // 6: messages.ChangeQuantityRequest.payload:type_name -> messages.ChangeQuantity + 21, // 7: messages.SetDeliveryRequest.payload:type_name -> messages.SetDelivery + 22, // 8: messages.SetPickupPointRequest.payload:type_name -> messages.SetPickupPoint + 23, // 9: messages.CreateCheckoutOrderRequest.payload:type_name -> messages.CreateCheckoutOrder + 24, // 10: messages.SetCartItemsRequest.payload:type_name -> messages.SetCartRequest + 25, // 11: messages.OrderCompletedRequest.payload:type_name -> messages.OrderCreated + 14, // 12: messages.CartState.items:type_name -> messages.CartItemState + 15, // 13: messages.CartState.deliveries:type_name -> messages.DeliveryState + 26, // 14: messages.DeliveryState.pickup_point:type_name -> messages.PickupPoint + 3, // 15: messages.CartActor.AddRequest:input_type -> messages.AddRequestRequest + 4, // 16: messages.CartActor.AddItem:input_type -> messages.AddItemRequest + 5, // 17: messages.CartActor.RemoveItem:input_type -> messages.RemoveItemRequest + 6, // 18: messages.CartActor.RemoveDelivery:input_type -> messages.RemoveDeliveryRequest + 7, // 19: messages.CartActor.ChangeQuantity:input_type -> messages.ChangeQuantityRequest + 8, // 20: messages.CartActor.SetDelivery:input_type -> messages.SetDeliveryRequest + 9, // 21: messages.CartActor.SetPickupPoint:input_type -> messages.SetPickupPointRequest + 11, // 22: messages.CartActor.SetCartItems:input_type -> messages.SetCartItemsRequest + 12, // 23: messages.CartActor.OrderCompleted:input_type -> messages.OrderCompletedRequest + 1, // 24: messages.CartActor.GetState:input_type -> messages.StateRequest + 0, // 25: messages.CartActor.AddRequest:output_type -> messages.CartMutationReply + 0, // 26: messages.CartActor.AddItem:output_type -> messages.CartMutationReply + 0, // 27: messages.CartActor.RemoveItem:output_type -> messages.CartMutationReply + 0, // 28: messages.CartActor.RemoveDelivery:output_type -> messages.CartMutationReply + 0, // 29: messages.CartActor.ChangeQuantity:output_type -> messages.CartMutationReply + 0, // 30: messages.CartActor.SetDelivery:output_type -> messages.CartMutationReply + 0, // 31: messages.CartActor.SetPickupPoint:output_type -> messages.CartMutationReply + 0, // 32: messages.CartActor.SetCartItems:output_type -> messages.CartMutationReply + 0, // 33: messages.CartActor.OrderCompleted:output_type -> messages.CartMutationReply + 2, // 34: messages.CartActor.GetState:output_type -> messages.StateReply + 25, // [25:35] is the sub-list for method output_type + 15, // [15:25] is the sub-list for method input_type 15, // [15:15] is the sub-list for extension type_name 15, // [15:15] is the sub-list for extension extendee 0, // [0:15] is the sub-list for field type_name @@ -1067,22 +1506,10 @@ func file_cart_actor_proto_init() { } file_messages_proto_init() file_cart_actor_proto_msgTypes[0].OneofWrappers = []any{ - (*MutationEnvelope_AddRequest)(nil), - (*MutationEnvelope_AddItem)(nil), - (*MutationEnvelope_RemoveItem)(nil), - (*MutationEnvelope_RemoveDelivery)(nil), - (*MutationEnvelope_ChangeQuantity)(nil), - (*MutationEnvelope_SetDelivery)(nil), - (*MutationEnvelope_SetPickupPoint)(nil), - (*MutationEnvelope_CreateCheckoutOrder)(nil), - (*MutationEnvelope_SetCartItems)(nil), - (*MutationEnvelope_OrderCompleted)(nil), + (*CartMutationReply_State)(nil), + (*CartMutationReply_Error)(nil), } - file_cart_actor_proto_msgTypes[1].OneofWrappers = []any{ - (*MutationReply_State)(nil), - (*MutationReply_Error)(nil), - } - file_cart_actor_proto_msgTypes[6].OneofWrappers = []any{ + file_cart_actor_proto_msgTypes[2].OneofWrappers = []any{ (*StateReply_State)(nil), (*StateReply_Error)(nil), } @@ -1092,7 +1519,7 @@ func file_cart_actor_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_cart_actor_proto_rawDesc), len(file_cart_actor_proto_rawDesc)), NumEnums: 0, - NumMessages: 7, + NumMessages: 16, NumExtensions: 0, NumServices: 1, }, diff --git a/proto/cart_actor.proto b/proto/cart_actor.proto index b09a1c2..9da8b8d 100644 --- a/proto/cart_actor.proto +++ b/proto/cart_actor.proto @@ -3,66 +3,114 @@ syntax = "proto3"; package messages; option go_package = "git.tornberg.me/go-cart-actor/proto;messages"; + 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 -// approach and replaces it with a strongly typed oneof envelope. Each concrete -// mutation proto is embedded directly, enabling: -// * Type-safe routing server-side (simple type switch on the oneof). -// * Direct persistence of MutationEnvelope messages (no custom binary header). -// * Elimination of the legacy message handler registry. +// This version removes the previous MutationEnvelope + Mutate RPC. +// Each mutation now has its own request wrapper and dedicated RPC method +// providing simpler, type-focused client stubs and enabling per-mutation +// metrics, auth and rate limiting. // -// NOTE: Regenerate Go code after editing: +// Regenerate Go code after editing: // protoc --go_out=. --go_opt=paths=source_relative \ // --go-grpc_out=. --go-grpc_opt=paths=source_relative \ // 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. -// client_timestamp: -// - Optional Unix timestamp provided by the client. -// - If zero the server MAY overwrite with its local time. -message MutationEnvelope { - 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; +// Shared reply for all mutation RPCs. +message CartMutationReply { + int32 status_code = 1; // HTTP-like status (200 success, 4xx client, 5xx server) + oneof result { + 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) } -// MutationReply returns a legacy-style status code plus a JSON payload -// holding either the updated cart state (on success) or an error string. -message MutationReply { - int32 status_code = 1; +// Fetch current cart state without mutation. +message StateRequest { + string cart_id = 1; +} - // Exactly one of state or error will be set. +message StateReply { + int32 status_code = 1; oneof result { CartState state = 2; string error = 3; } } -// StateRequest fetches current cart state without mutating. -message StateRequest { +// Per-mutation request wrappers. We wrap the existing inner mutation +// messages (defined in messages.proto) to add cart_id + optional metadata +// without altering the inner message definitions. + +message AddRequestRequest { 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. -// Replaces the previous raw JSON payload. +// Cart state snapshot (unchanged from v1 except envelope removal context) // ----------------------------------------------------------------------------- message CartState { string cart_id = 1; @@ -76,7 +124,6 @@ message CartState { string payment_status = 9; } -// Lightweight representation of an item in the cart message CartItemState { int64 id = 1; int64 source_item_id = 2; @@ -105,38 +152,37 @@ message CartItemState { int32 stock = 25; } -// Delivery / shipping entry message DeliveryState { int64 id = 1; string provider = 2; int64 price = 3; repeated int64 item_ids = 4; - PickupPoint pickup_point = 5; + PickupPoint pickup_point = 5; // Defined in messages.proto } -// StateReply mirrors MutationReply for consistency. -message StateReply { - int32 status_code = 1; +// (CheckoutRequest / CheckoutReply removed - checkout handled at HTTP layer) - oneof result { - CartState state = 2; - string error = 3; - } -} - -// CartActor exposes mutation and state retrieval for remote grains. +// ----------------------------------------------------------------------------- +// Service definition (per-mutation RPCs + checkout) +// ----------------------------------------------------------------------------- service CartActor { - // Mutate applies a single mutation to a cart, creating the cart lazily if needed. - rpc Mutate(MutationEnvelope) returns (MutationReply); + rpc AddRequest(AddRequestRequest) returns (CartMutationReply); + 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); } // ----------------------------------------------------------------------------- -// Future Enhancements: -// * Replace JSON state payload with a strongly typed CartState proto. -// * Add streaming RPC (e.g., WatchState) for live updates. -// * Add batch mutations (repeated MutationEnvelope) if performance requires. -// * Introduce optimistic concurrency via version fields if external writers appear. +// Future enhancements: +// * BatchMutate RPC (repeated heterogeneous mutations) +// * Streaming state updates (WatchState) +// * Versioning / optimistic concurrency control // ----------------------------------------------------------------------------- diff --git a/proto/cart_actor_grpc.pb.go b/proto/cart_actor_grpc.pb.go index 247f758..bd34ce3 100644 --- a/proto/cart_actor_grpc.pb.go +++ b/proto/cart_actor_grpc.pb.go @@ -19,19 +19,36 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - CartActor_Mutate_FullMethodName = "/messages.CartActor/Mutate" - CartActor_GetState_FullMethodName = "/messages.CartActor/GetState" + 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" ) // CartActorClient is the client API for CartActor service. // // 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 { - // Mutate applies a single mutation to a cart, creating the cart lazily if needed. - Mutate(ctx context.Context, in *MutationEnvelope, opts ...grpc.CallOption) (*MutationReply, error) - // GetState retrieves the cart's current state (JSON). + AddRequest(ctx context.Context, in *AddRequestRequest, opts ...grpc.CallOption) (*CartMutationReply, error) + AddItem(ctx context.Context, in *AddItemRequest, opts ...grpc.CallOption) (*CartMutationReply, error) + 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) } @@ -43,10 +60,90 @@ func NewCartActorClient(cc grpc.ClientConnInterface) CartActorClient { 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...) - out := new(MutationReply) - err := c.cc.Invoke(ctx, CartActor_Mutate_FullMethodName, in, out, cOpts...) + out := new(CartMutationReply) + 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 { return nil, err } @@ -67,11 +164,20 @@ func (c *cartActorClient) GetState(ctx context.Context, in *StateRequest, opts . // All implementations must embed UnimplementedCartActorServer // for forward compatibility. // -// CartActor exposes mutation and state retrieval for remote grains. +// ----------------------------------------------------------------------------- +// Service definition (per-mutation RPCs + checkout) +// ----------------------------------------------------------------------------- type CartActorServer interface { - // Mutate applies a single mutation to a cart, creating the cart lazily if needed. - Mutate(context.Context, *MutationEnvelope) (*MutationReply, error) - // GetState retrieves the cart's current state (JSON). + AddRequest(context.Context, *AddRequestRequest) (*CartMutationReply, error) + AddItem(context.Context, *AddItemRequest) (*CartMutationReply, error) + 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) mustEmbedUnimplementedCartActorServer() } @@ -83,8 +189,32 @@ type CartActorServer interface { // pointer dereference when methods are called. type UnimplementedCartActorServer struct{} -func (UnimplementedCartActorServer) Mutate(context.Context, *MutationEnvelope) (*MutationReply, error) { - return nil, status.Errorf(codes.Unimplemented, "method Mutate not implemented") +func (UnimplementedCartActorServer) AddRequest(context.Context, *AddRequestRequest) (*CartMutationReply, error) { + 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) { 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) } -func _CartActor_Mutate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(MutationEnvelope) +func _CartActor_AddRequest_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AddRequestRequest) if err := dec(in); err != nil { return nil, err } if interceptor == nil { - return srv.(CartActorServer).Mutate(ctx, in) + return srv.(CartActorServer).AddRequest(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: CartActor_Mutate_FullMethodName, + FullMethod: CartActor_AddRequest_FullMethodName, } 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) } @@ -154,8 +428,40 @@ var CartActor_ServiceDesc = grpc.ServiceDesc{ HandlerType: (*CartActorServer)(nil), Methods: []grpc.MethodDesc{ { - MethodName: "Mutate", - Handler: _CartActor_Mutate_Handler, + MethodName: "AddRequest", + 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", diff --git a/proto/messages.pb.go b/proto/messages.pb.go index add0f30..506bb46 100644 --- a/proto/messages.pb.go +++ b/proto/messages.pb.go @@ -889,6 +889,102 @@ func (x *OrderCreated) GetStatus() string { 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 const file_messages_proto_rawDesc = "" + @@ -997,7 +1093,12 @@ const file_messages_proto_rawDesc = "" + "\acountry\x18\x06 \x01(\tR\acountry\"@\n" + "\fOrderCreated\x12\x18\n" + "\aorderId\x18\x01 \x01(\tR\aorderId\x12\x16\n" + - "\x06status\x18\x02 \x01(\tR\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 ( file_messages_proto_rawDescOnce sync.Once @@ -1011,7 +1112,7 @@ func file_messages_proto_rawDescGZIP() []byte { 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{ (*AddRequest)(nil), // 0: messages.AddRequest (*SetCartRequest)(nil), // 1: messages.SetCartRequest @@ -1024,6 +1125,8 @@ var file_messages_proto_goTypes = []any{ (*RemoveDelivery)(nil), // 8: messages.RemoveDelivery (*CreateCheckoutOrder)(nil), // 9: messages.CreateCheckoutOrder (*OrderCreated)(nil), // 10: messages.OrderCreated + (*Noop)(nil), // 11: messages.Noop + (*InitializeCheckout)(nil), // 12: messages.InitializeCheckout } var file_messages_proto_depIdxs = []int32{ 0, // 0: messages.SetCartRequest.items:type_name -> messages.AddRequest @@ -1051,7 +1154,7 @@ func file_messages_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_messages_proto_rawDesc), len(file_messages_proto_rawDesc)), NumEnums: 0, - NumMessages: 11, + NumMessages: 13, NumExtensions: 0, NumServices: 0, }, diff --git a/proto/messages.proto b/proto/messages.proto index 90f1008..52d09ab 100644 --- a/proto/messages.proto +++ b/proto/messages.proto @@ -93,3 +93,13 @@ message OrderCreated { string orderId = 1; 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; +} diff --git a/remote-grain-pool._go b/remote-grain-pool._go deleted file mode 100644 index bc13b41..0000000 --- a/remote-grain-pool._go +++ /dev/null @@ -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() -// } diff --git a/remote_grain_grpc.go b/remote_grain_grpc.go index 81d7492..bcc530c 100644 --- a/remote_grain_grpc.go +++ b/remote_grain_grpc.go @@ -67,8 +67,8 @@ func (g *RemoteGrainGRPC) GetId() CartId { return g.Id } -// Apply executes a cart mutation remotely using the typed oneof MutationEnvelope -// and returns a *CartGrain reconstructed from the typed MutationReply (state oneof). +// Apply executes a cart mutation via per-mutation RPCs (breaking v2 API) +// and returns a *CartGrain reconstructed from the CartMutationReply state. func (g *RemoteGrainGRPC) Apply(content interface{}, isReplay bool) (*CartGrain, error) { if isReplay { 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() - env := &proto.MutationEnvelope{ - CartId: g.Id.String(), - ClientTimestamp: ts, - } + + var invoke func(ctx context.Context) (*proto.CartMutationReply, error) switch m := content.(type) { 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: - 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: - 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: - 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: - 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: - 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: - 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: - env.Mutation = &proto.MutationEnvelope_CreateCheckoutOrder{CreateCheckoutOrder: m} + return nil, fmt.Errorf("CreateCheckoutOrder deprecated: checkout is handled via HTTP endpoint (HandleCheckout)") 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: - 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: 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) defer cancel() - resp, err := g.client.Mutate(ctx, env) + resp, err := invoke(ctx) if err != nil { return nil, err } - // Map typed reply if resp.StatusCode < 200 || resp.StatusCode >= 300 { if e := resp.GetError(); e != "" { return nil, fmt.Errorf("remote mutation failed %d: %s", resp.StatusCode, e) diff --git a/synced-pool.go b/synced-pool.go index dfb7494..50535b1 100644 --- a/synced-pool.go +++ b/synced-pool.go @@ -426,20 +426,13 @@ func (p *SyncedPool) getGrain(id CartId) (Grain, error) { return grain, nil } -// Process applies mutation(s) to a grain (local or remote). -func (p *SyncedPool) Process(id CartId, mutations ...interface{}) (*CartGrain, error) { +// Apply applies a single mutation to a grain (local or remote). +func (p *SyncedPool) Apply(id CartId, mutation interface{}) (*CartGrain, error) { grain, err := p.getGrain(id) if err != nil { return nil, err } - var res *CartGrain - for _, m := range mutations { - res, err = grain.Apply(m, false) - if err != nil { - return nil, err - } - } - return res, nil + return grain.Apply(mutation, false) } // Get returns current state of a grain (local or remote).