From 9aa587b9cfe80ed21837abdbe9dd9acaf4aa67fa Mon Sep 17 00:00:00 2001 From: matst80 Date: Wed, 24 Jun 2026 10:50:32 +0200 Subject: [PATCH] mcp and ucp --- cmd/cart/main.go | 30 ++ cmd/checkout/main.go | 37 +++ cmd/order/main.go | 30 ++ internal/ucp/cart.go | 321 ++++++++++++++++++++ internal/ucp/cart_test.go | 236 +++++++++++++++ internal/ucp/checkout.go | 550 +++++++++++++++++++++++++++++++++++ internal/ucp/handler.go | 117 ++++++++ internal/ucp/order.go | 329 +++++++++++++++++++++ internal/ucp/order_test.go | 300 +++++++++++++++++++ internal/ucp/signing.go | 183 ++++++++++++ internal/ucp/signing_test.go | 173 +++++++++++ internal/ucp/types.go | 397 +++++++++++++++++++++++++ pkg/cart/mcp/jsonrpc.go | 44 +++ pkg/cart/mcp/mcp.go | 159 ++++++++++ pkg/cart/mcp/mcp_test.go | 421 +++++++++++++++++++++++++++ pkg/cart/mcp/tools.go | 439 ++++++++++++++++++++++++++++ pkg/order/mcp/jsonrpc.go | 44 +++ pkg/order/mcp/mcp.go | 158 ++++++++++ pkg/order/mcp/mcp_test.go | 457 +++++++++++++++++++++++++++++ pkg/order/mcp/tools.go | 519 +++++++++++++++++++++++++++++++++ 20 files changed, 4944 insertions(+) create mode 100644 internal/ucp/cart.go create mode 100644 internal/ucp/cart_test.go create mode 100644 internal/ucp/checkout.go create mode 100644 internal/ucp/handler.go create mode 100644 internal/ucp/order.go create mode 100644 internal/ucp/order_test.go create mode 100644 internal/ucp/signing.go create mode 100644 internal/ucp/signing_test.go create mode 100644 internal/ucp/types.go create mode 100644 pkg/cart/mcp/jsonrpc.go create mode 100644 pkg/cart/mcp/mcp.go create mode 100644 pkg/cart/mcp/mcp_test.go create mode 100644 pkg/cart/mcp/tools.go create mode 100644 pkg/order/mcp/jsonrpc.go create mode 100644 pkg/order/mcp/mcp.go create mode 100644 pkg/order/mcp/mcp_test.go create mode 100644 pkg/order/mcp/tools.go diff --git a/cmd/cart/main.go b/cmd/cart/main.go index a314534..e2e9c78 100644 --- a/cmd/cart/main.go +++ b/cmd/cart/main.go @@ -16,6 +16,7 @@ import ( "git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/mats/go-cart-actor/pkg/promotions" promotionmcp "git.k6n.net/mats/go-cart-actor/pkg/promotions/mcp" + "git.k6n.net/mats/go-cart-actor/internal/ucp" "git.k6n.net/mats/go-cart-actor/pkg/proxy" "git.k6n.net/mats/go-cart-actor/pkg/voucher" "git.k6n.net/mats/go-redis-inventory/pkg/inventory" @@ -66,6 +67,26 @@ func normalizeListenAddr(v string) string { return ":" + v } +// loadUCPCartSigner loads the UCP ECDSA signing key from the path specified in +// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable. +func loadUCPCartSigner() *ucp.SigningConfig { + path := os.Getenv("UCP_SIGNING_KEY_PATH") + if path == "" { + return nil + } + pemData, err := os.ReadFile(path) + if err != nil { + log.Printf("ucp signing: cannot read key %s: %v", path, err) + return nil + } + cfg, err := ucp.NewSigningConfigFromPEM(pemData, "k6n-ecdsa-2026") + if err != nil { + log.Printf("ucp signing: invalid key at %s: %v", path, err) + return nil + } + return cfg +} + func getCountryFromHost(host string) string { if strings.Contains(strings.ToLower(host), "-no") { return "no" @@ -278,6 +299,15 @@ func main() { mux.Handle("/mcp", promotionMCP.Handler()) mux.Handle("/mcp/", promotionMCP.Handler()) + // UCP REST adapter — Universal Commerce Protocol + cartUCP := ucp.CartHandler(pool) + if signer := loadUCPCartSigner(); signer != nil { + cartUCP = ucp.WithSigning(cartUCP, signer) + log.Print("ucp signing enabled") + } + mux.Handle("/ucp/v1/carts", cartUCP) + mux.Handle("/ucp/v1/carts/", cartUCP) + // Stateless promotion evaluation: POST a (possibly partial) eval context and // get back the resulting totals plus the applied/pending effect breakdown, // without creating or mutating a real cart. diff --git a/cmd/checkout/main.go b/cmd/checkout/main.go index 826bb4b..95311a7 100644 --- a/cmd/checkout/main.go +++ b/cmd/checkout/main.go @@ -10,6 +10,7 @@ import ( "os/signal" "time" + "git.k6n.net/mats/go-cart-actor/internal/ucp" "git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/mats/go-cart-actor/pkg/checkout" "git.k6n.net/mats/go-cart-actor/pkg/proxy" @@ -48,6 +49,26 @@ var redisAddress = os.Getenv("REDIS_ADDRESS") var redisPassword = os.Getenv("REDIS_PASSWORD") var cartInternalUrl = os.Getenv("CART_INTERNAL_URL") // e.g., http://cart-service:8081 +// loadUCPCheckoutSigner loads the UCP ECDSA signing key from the path specified in +// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable. +func loadUCPCheckoutSigner() *ucp.SigningConfig { + path := os.Getenv("UCP_SIGNING_KEY_PATH") + if path == "" { + return nil + } + pemData, err := os.ReadFile(path) + if err != nil { + log.Printf("ucp signing: cannot read key %s: %v", path, err) + return nil + } + cfg, err := ucp.NewSigningConfigFromPEM(pemData, "k6n-ecdsa-2026") + if err != nil { + log.Printf("ucp signing: invalid key at %s: %v", path, err) + return nil + } + return cfg +} + func main() { controlPlaneConfig := actor.DefaultServerConfig() @@ -160,6 +181,22 @@ func main() { syncedServer.Serve(mux) + // UCP REST adapter — Universal Commerce Protocol checkout sessions + // When an order service URL is configured, the complete endpoint creates + // real orders via the order service's from-checkout API. + var ucpOrderSvc ucp.OrderApplier + if orderServiceURL := os.Getenv("ORDER_SERVICE_URL"); orderServiceURL != "" { + ucpOrderSvc = ucp.NewOrderHTTPClient(orderServiceURL) + log.Printf("ucp order applier enabled: %s", orderServiceURL) + } + checkoutUCP := ucp.CheckoutHandler(pool, ucpOrderSvc) + if signer := loadUCPCheckoutSigner(); signer != nil { + checkoutUCP = ucp.WithSigning(checkoutUCP, signer) + log.Print("ucp signing enabled") + } + mux.Handle("/ucp/v1/checkout-sessions", checkoutUCP) + mux.Handle("/ucp/v1/checkout-sessions/", checkoutUCP) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { grainCount, capacity := pool.LocalUsage() if grainCount >= capacity { diff --git a/cmd/order/main.go b/cmd/order/main.go index 703c8c9..00f3693 100644 --- a/cmd/order/main.go +++ b/cmd/order/main.go @@ -19,6 +19,7 @@ import ( "sync" "time" + "git.k6n.net/mats/go-cart-actor/internal/ucp" "git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/mats/go-cart-actor/pkg/flow" "git.k6n.net/mats/go-cart-actor/pkg/idempotency" @@ -155,6 +156,15 @@ func main() { mux.HandleFunc("POST /api/orders/{id}/returns", s.handleReturn) mux.HandleFunc("POST /api/orders/{id}/refunds", s.handleRefund) + // UCP REST adapter — Universal Commerce Protocol order lifecycle. + orderUCP := ucp.OrderHandler(s.applier) + if signer := loadUCPOrderSigner(); signer != nil { + orderUCP = ucp.WithSigning(orderUCP, signer) + logger.Info("ucp signing enabled") + } + mux.Handle("/ucp/v1/orders", orderUCP) + mux.Handle("/ucp/v1/orders/", orderUCP) + // Saga / flow admin (backoffice editor). mux.HandleFunc("GET /sagas/capabilities", s.handleCapabilities) mux.HandleFunc("GET /sagas/flows", s.handleListFlows) @@ -444,6 +454,26 @@ func selectProvider(logger *slog.Logger) order.PaymentProvider { return order.NewMockProvider() } +// loadUCPOrderSigner loads the UCP ECDSA signing key from the path specified in +// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable. +func loadUCPOrderSigner() *ucp.SigningConfig { + path := os.Getenv("UCP_SIGNING_KEY_PATH") + if path == "" { + return nil + } + pemData, err := os.ReadFile(path) + if err != nil { + log.Printf("ucp signing: cannot read key %s: %v", path, err) + return nil + } + cfg, err := ucp.NewSigningConfigFromPEM(pemData, "k6n-ecdsa-2026") + if err != nil { + log.Printf("ucp signing: invalid key at %s: %v", path, err) + return nil + } + return cfg +} + func envOr(key, def string) string { if v := os.Getenv(key); v != "" { return v diff --git a/internal/ucp/cart.go b/internal/ucp/cart.go new file mode 100644 index 0000000..002db7a --- /dev/null +++ b/internal/ucp/cart.go @@ -0,0 +1,321 @@ +package ucp + +import ( + "context" + "encoding/json" + "net/http" + "sync" + + "git.k6n.net/mats/go-cart-actor/pkg/cart" + "git.k6n.net/mats/go-cart-actor/pkg/actor" + messages "git.k6n.net/mats/go-cart-actor/proto/cart" + "google.golang.org/protobuf/proto" +) + +// CartServer is the UCP REST adapter over the cart grain pool. +type CartServer struct { + applier CartApplier + newCartId func() (cart.CartId, error) +} + +// NewCartServer builds a UCP REST adapter over a cart grain pool. +func NewCartServer(applier CartApplier) *CartServer { + return &CartServer{ + applier: applier, + newCartId: cart.NewCartId, + } +} + +// --------------------------------------------------------------------------- +// UCP Cart endpoints +// --------------------------------------------------------------------------- + +// handleCreateCart handles POST /carts. +func (s *CartServer) handleCreateCart(w http.ResponseWriter, r *http.Request) { + cid, err := s.newCartId() + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to generate cart id") + return + } + id := uint64(cid) + + // Seed the cart grain by calling Get once (spawns if new). + if _, err := s.applier.Get(r.Context(), id); err != nil { + writeError(w, http.StatusInternalServerError, "failed to create cart: "+err.Error()) + return + } + + // If items were supplied, add them. + var req CreateCartRequest + hasBody := false + if r.ContentLength > 0 { + if err := json.NewDecoder(r.Body).Decode(&req); err == nil { + hasBody = true + } + } + if hasBody && len(req.Items) > 0 { + groups := buildItemGroups(r.Context(), req.Items, "") + if _, err := applyItemGroups(r.Context(), s.applier, id, groups); err != nil { + writeError(w, http.StatusInternalServerError, "failed to add items: "+err.Error()) + return + } + } + + g, err := s.applier.Get(r.Context(), id) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to read cart: "+err.Error()) + return + } + writeJSON(w, http.StatusCreated, cartGrainToResponse(cid.String(), g)) +} + +// handleGetCart handles GET /carts/{id}. +func (s *CartServer) handleGetCart(w http.ResponseWriter, r *http.Request) { + id, ok := parseCartID(r) + if !ok { + writeError(w, http.StatusBadRequest, "invalid cart id") + return + } + g, err := s.applier.Get(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, "cart not found") + return + } + writeJSON(w, http.StatusOK, cartGrainToResponse(r.PathValue("id"), g)) +} + +// handleUpdateCart handles PUT /carts/{id}. +func (s *CartServer) handleUpdateCart(w http.ResponseWriter, r *http.Request) { + id, ok := parseCartID(r) + if !ok { + writeError(w, http.StatusBadRequest, "invalid cart id") + return + } + + var req UpdateCartRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + + // Clear first, then apply. + if _, err := s.applier.Apply(r.Context(), id, &messages.ClearCartRequest{}); err != nil { + writeError(w, http.StatusInternalServerError, "failed to clear cart: "+err.Error()) + return + } + + // Add items. + if len(req.Items) > 0 { + groups := buildItemGroups(r.Context(), req.Items, req.Country) + if _, err := applyItemGroups(r.Context(), s.applier, id, groups); err != nil { + writeError(w, http.StatusInternalServerError, "failed to add items: "+err.Error()) + return + } + } + + // Set user ID. + if req.UserId != nil && *req.UserId != "" { + if _, err := s.applier.Apply(r.Context(), id, &messages.SetUserId{UserId: *req.UserId}); err != nil { + writeError(w, http.StatusInternalServerError, "failed to set user: "+err.Error()) + return + } + } + + g, err := s.applier.Get(r.Context(), id) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to read cart: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, cartGrainToResponse(r.PathValue("id"), g)) +} + +// handleCancelCart handles POST /carts/{id}/cancel. +func (s *CartServer) handleCancelCart(w http.ResponseWriter, r *http.Request) { + id, ok := parseCartID(r) + if !ok { + writeError(w, http.StatusBadRequest, "invalid cart id") + return + } + + if _, err := s.applier.Apply(r.Context(), id, &messages.ClearCartRequest{}); err != nil { + writeError(w, http.StatusInternalServerError, "failed to clear cart: "+err.Error()) + return + } + + g, err := s.applier.Get(r.Context(), id) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to read cart: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, cartGrainToResponse(r.PathValue("id"), g)) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func parseCartID(r *http.Request) (uint64, bool) { + raw := r.PathValue("id") + if raw == "" { + return 0, false + } + cid, ok := cart.ParseCartId(raw) + if !ok { + return 0, false + } + return uint64(cid), true +} + +func cartGrainToResponse(id string, g *cart.CartGrain) CartResponse { + resp := CartResponse{ + Id: id, + Items: make([]CartItem, 0, len(g.Items)), + Totals: Totals{Currency: g.Currency}, + Status: "active", + } + if g.CheckoutStatus != nil && *g.CheckoutStatus != "" { + resp.Status = string(*g.CheckoutStatus) + } + + for _, it := range g.Items { + if it == nil { + continue + } + item := CartItem{ + Sku: it.Sku, + Quantity: int(it.Quantity), + UnitPrice: it.Price.IncVat, + TotalPrice: it.TotalPrice.IncVat, + TaxRate: it.Tax, + ItemId: it.Id, + Fields: it.CustomFields, + } + if it.Meta != nil { + item.Name = it.Meta.Name + item.Image = it.Meta.Image + } + resp.Items = append(resp.Items, item) + } + + if g.TotalPrice != nil { + resp.Totals.TotalIncVat = g.TotalPrice.IncVat + resp.Totals.TotalExVat = g.TotalPrice.ValueExVat() + resp.Totals.TotalVat = g.TotalPrice.TotalVat() + } + if g.TotalDiscount != nil { + resp.Totals.Discount = g.TotalDiscount.IncVat + } + + return resp +} + +// --------------------------------------------------------------------------- +// Item group building (adapted from cmd/cart/pool-server.go) +// --------------------------------------------------------------------------- + +type itemGroup struct { + parent *messages.AddItem + children []*messages.AddItem +} + +func buildItemGroups(_ context.Context, items []CartItemInput, _ string) []itemGroup { + groups := make([]itemGroup, len(items)) + var mu sync.Mutex + var wg sync.WaitGroup + for i, itm := range items { + wg.Add(1) + go func(i int, itm CartItemInput) { + defer wg.Done() + parentMsg := buildAddItem(itm, 0) + mu.Lock() + groups[i].parent = parentMsg + mu.Unlock() + if len(itm.Children) > 0 { + children := make([]*messages.AddItem, len(itm.Children)) + for j, child := range itm.Children { + children[j] = buildAddItem(child, parentMsg.ItemId) + } + mu.Lock() + groups[i].children = children + mu.Unlock() + } + }(i, itm) + } + wg.Wait() + return groups +} + +func buildAddItem(input CartItemInput, parentItemId uint32) *messages.AddItem { + qty := int32(input.Quantity) + if qty <= 0 { + qty = 1 + } + msg := &messages.AddItem{ + Sku: input.Sku, + Quantity: qty, + } + if input.StoreId != nil { + msg.StoreId = input.StoreId + } + if input.Sku != "" { + // Pass along the item id so findParentLineId can match. + msg.ItemId = parentItemId + } + if len(input.Fields) > 0 { + msg.CustomFields = input.Fields + } + if parentItemId > 0 { + msg.ParentId = &parentItemId + } + return msg +} + +func applyItemGroups(ctx context.Context, applier CartApplier, id uint64, groups []itemGroup) (*actor.MutationResult[cart.CartGrain], error) { + var last *actor.MutationResult[cart.CartGrain] + for _, g := range groups { + if g.parent == nil { + continue + } + res, err := applier.Apply(ctx, id, g.parent) + if err != nil { + return nil, err + } + last = res + + if len(g.children) == 0 { + continue + } + parentLineId, ok := findParentLineId(&res.Result, g.parent) + if !ok { + continue + } + childMsgs := make([]proto.Message, len(g.children)) + for i, c := range g.children { + c.ParentId = &parentLineId + childMsgs[i] = c + } + res, err = applier.Apply(ctx, id, childMsgs...) + if err != nil { + return nil, err + } + last = res + } + return last, nil +} + +func findParentLineId(grain *cart.CartGrain, parent *messages.AddItem) (uint32, bool) { + for _, it := range grain.Items { + if it == nil || it.ParentId != nil { + continue + } + if it.ItemId != parent.ItemId { + continue + } + sameStore := (it.StoreId == nil && parent.StoreId == nil) || + (it.StoreId != nil && parent.StoreId != nil && *it.StoreId == *parent.StoreId) + if sameStore { + return it.Id, true + } + } + return 0, false +} diff --git a/internal/ucp/cart_test.go b/internal/ucp/cart_test.go new file mode 100644 index 0000000..ea7ae7a --- /dev/null +++ b/internal/ucp/cart_test.go @@ -0,0 +1,236 @@ +package ucp + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.k6n.net/mats/go-cart-actor/pkg/cart" + "git.k6n.net/mats/go-cart-actor/pkg/actor" + messages "git.k6n.net/mats/go-cart-actor/proto/cart" + "google.golang.org/protobuf/proto" +) + +// testApplier implements CartApplier with an in-memory map of grains. +type testApplier struct { + grains map[uint64]*cart.CartGrain +} + +func newTestApplier() *testApplier { + return &testApplier{ + grains: make(map[uint64]*cart.CartGrain), + } +} + +func (a *testApplier) Get(_ context.Context, id uint64) (*cart.CartGrain, error) { + g, ok := a.grains[id] + if !ok { + g = cart.NewCartGrain(id, time.UnixMilli(1000000)) + a.grains[id] = g + } + return g, nil +} + +func (a *testApplier) Apply(_ context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[cart.CartGrain], error) { + g, err := a.Get(nil, id) + if err != nil { + return nil, err + } + results := make([]actor.ApplyResult, len(msgs)) + for i, msg := range msgs { + results[i] = actor.ApplyResult{Type: string(msg.ProtoReflect().Descriptor().FullName())} + switch m := msg.(type) { + case *messages.ClearCartRequest: + g.Items = nil + g.Vouchers = nil + g.TotalDiscount = cart.NewPrice() + g.TotalPrice = cart.NewPrice() + case *messages.SetUserId: + // userId is unexported; rely on serialization to verify. + _ = m + } + } + return &actor.MutationResult[cart.CartGrain]{ + Result: *g, + Mutations: results, + }, nil +} + +// mustParseID returns the uint64 representation of a base62 cart id string. +func mustParseID(s string) uint64 { + id, _ := cart.ParseCartId(s) + return uint64(id) +} + +func TestCartHandler_CreateCart(t *testing.T) { + applier := newTestApplier() + handler := CartHandler(applier) + + req := httptest.NewRequest("POST", "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201 Created, got %d", rec.Code) + } + + var resp CartResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.Id == "" { + t.Fatal("expected non-empty cart id") + } + if resp.Status != "active" { + t.Fatalf("expected status 'active', got %q", resp.Status) + } +} + +func TestCartHandler_GetCartNotFound(t *testing.T) { + applier := newTestApplier() + handler := CartHandler(applier) + + req := httptest.NewRequest("GET", "/nonexistent", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + // /nonexistent is a valid base62 string, so it parses, but the cart + // auto-creates on first Get, so we get a 200 with an empty cart. + // This is expected behavior (UCP auto-creates carts on read). + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 (auto-create), got %d", rec.Code) + } +} + +func TestCartHandler_CreateAndGet(t *testing.T) { + applier := newTestApplier() + handler := CartHandler(applier) + + // Create a cart. + req := httptest.NewRequest("POST", "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("create failed: %d", rec.Code) + } + + var created CartResponse + json.NewDecoder(rec.Body).Decode(&created) + + // GET it back. + req = httptest.NewRequest("GET", "/"+created.Id, nil) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + + var got CartResponse + json.NewDecoder(rec.Body).Decode(&got) + if got.Id != created.Id { + t.Fatalf("expected id %q, got %q", created.Id, got.Id) + } +} + +func TestCartHandler_CancelCart(t *testing.T) { + applier := newTestApplier() + handler := CartHandler(applier) + + // Create a cart. + req := httptest.NewRequest("POST", "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + var created CartResponse + json.NewDecoder(rec.Body).Decode(&created) + + // Cancel it. + req = httptest.NewRequest("POST", "/"+created.Id+"/cancel", nil) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + + var cancelled CartResponse + json.NewDecoder(rec.Body).Decode(&cancelled) + if cancelled.Id != created.Id { + t.Fatalf("expected same id %q, got %q", created.Id, cancelled.Id) + } +} + +func TestCartHandler_UpdateCart(t *testing.T) { + applier := newTestApplier() + handler := CartHandler(applier) + + // Create a cart. + req := httptest.NewRequest("POST", "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + var created CartResponse + json.NewDecoder(rec.Body).Decode(&created) + + // Update with items (test applier doesn't process AddItem, but the + // handler still returns 200 — items are populated by the real grain pool). + updateBody := `{"items": [{"sku": "test-sku", "quantity": 2, "name": "Test Item"}]}` + req = httptest.NewRequest("PUT", "/"+created.Id, strings.NewReader(updateBody)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var updated CartResponse + json.NewDecoder(rec.Body).Decode(&updated) + if updated.Id != created.Id { + t.Fatalf("expected same id %q, got %q", created.Id, updated.Id) + } +} + +func TestCartResponse_Conversion(t *testing.T) { + id := mustParseID("ABCD") + g := cart.NewCartGrain(id, time.UnixMilli(1000000)) + g.Currency = "SEK" + g.Items = append(g.Items, &cart.CartItem{ + Sku: "test-1", + Quantity: 2, + Price: *mustPrice(10000, 2500), + TotalPrice: *mustPrice(20000, 5000), + Tax: 2500, + Meta: &cart.ItemMeta{Name: "Test Item"}, + }) + g.TotalPrice = mustPrice(20000, 5000) + + resp := cartGrainToResponse(g.Id.String(), g) + if len(resp.Items) != 1 { + t.Fatalf("expected 1 item, got %d", len(resp.Items)) + } + if resp.Items[0].Sku != "test-1" { + t.Fatalf("expected sku 'test-1', got %q", resp.Items[0].Sku) + } + if resp.Items[0].Name != "Test Item" { + t.Fatalf("expected name 'Test Item', got %q", resp.Items[0].Name) + } + if resp.Totals.TotalIncVat != 20000 { + t.Fatalf("expected total 20000, got %d", resp.Totals.TotalIncVat) + } + if resp.Totals.Currency != "SEK" { + t.Fatalf("expected currency SEK, got %q", resp.Totals.Currency) + } +} + +func mustPrice(incVat int64, totalVat int64) *cart.Price { + return &cart.Price{ + IncVat: incVat, + VatRates: map[float32]int64{25: totalVat}, + } +} diff --git a/internal/ucp/checkout.go b/internal/ucp/checkout.go new file mode 100644 index 0000000..02e298f --- /dev/null +++ b/internal/ucp/checkout.go @@ -0,0 +1,550 @@ +package ucp + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "git.k6n.net/mats/go-cart-actor/pkg/checkout" + "git.k6n.net/mats/go-cart-actor/pkg/cart" + checkoutMessages "git.k6n.net/mats/go-cart-actor/proto/checkout" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// CheckoutServer is the UCP REST adapter over the checkout grain pool. +type CheckoutServer struct { + applier CheckoutApplier + orderSvc OrderApplier // optional; when set creates real orders on complete +} + +// NewCheckoutServer builds a UCP REST adapter over a checkout grain pool. +func NewCheckoutServer(applier CheckoutApplier, orderSvc ...OrderApplier) *CheckoutServer { + s := &CheckoutServer{applier: applier} + if len(orderSvc) > 0 { + s.orderSvc = orderSvc[0] + } + return s +} + +// --------------------------------------------------------------------------- +// UCP Checkout endpoints +// --------------------------------------------------------------------------- + +// handleCreateCheckout handles POST /checkout-sessions. +// +// Initiates a checkout session from a cart. The cart id is required. +func (s *CheckoutServer) handleCreateCheckout(w http.ResponseWriter, r *http.Request) { + var req CreateCheckoutRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + if req.CartId == "" { + writeError(w, http.StatusBadRequest, "cartId is required") + return + } + + cartId, ok := cart.ParseCartId(req.CartId) + if !ok { + writeError(w, http.StatusBadRequest, "invalid cartId") + return + } + + // Create a new checkout session id. + checkoutId, err := cart.NewCartId() + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to generate checkout id") + return + } + + // Initialize checkout with cart reference. + initMsg := &checkoutMessages.InitializeCheckout{ + OrderId: "", + CartId: uint64(cartId), + } + if req.Country != "" { + // Pass country through the cart state snapshot (minimal). + initMsg.CartState = &anypb.Any{ + TypeUrl: "request/country", + Value: []byte(req.Country), + } + } + + if _, err := s.applier.Apply(r.Context(), uint64(checkoutId), initMsg); err != nil { + writeError(w, http.StatusInternalServerError, "failed to initialize checkout: "+err.Error()) + return + } + + g, err := s.applier.Get(r.Context(), uint64(checkoutId)) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to read checkout: "+err.Error()) + return + } + + writeJSON(w, http.StatusCreated, checkoutGrainToResponse(checkoutId.String(), g)) +} + +// handleGetCheckout handles GET /checkout-sessions/{id}. +func (s *CheckoutServer) handleGetCheckout(w http.ResponseWriter, r *http.Request) { + id, ok := parseSessionID(r) + if !ok { + writeError(w, http.StatusBadRequest, "invalid checkout session id") + return + } + + g, err := s.applier.Get(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, "checkout session not found") + return + } + + writeJSON(w, http.StatusOK, checkoutGrainToResponse(r.PathValue("id"), g)) +} + +// handleUpdateCheckout handles PUT /checkout-sessions/{id}. +// +// Updates checkout with delivery, contact, or pickup point info. +func (s *CheckoutServer) handleUpdateCheckout(w http.ResponseWriter, r *http.Request) { + id, ok := parseSessionID(r) + if !ok { + writeError(w, http.StatusBadRequest, "invalid checkout session id") + return + } + + var req UpdateCheckoutRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + + // Apply delivery. + if req.Delivery != nil { + msg := &checkoutMessages.SetDelivery{ + Provider: req.Delivery.Provider, + } + for _, itemId := range req.Delivery.ItemIds { + msg.Items = append(msg.Items, itemId) + } + if _, err := s.applier.Apply(r.Context(), id, msg); err != nil { + writeError(w, http.StatusInternalServerError, "failed to set delivery: "+err.Error()) + return + } + } + + // Apply pickup point. + if req.PickupPoint != nil { + pp := &checkoutMessages.SetPickupPoint{ + DeliveryId: req.PickupPoint.DeliveryId, + PickupPoint: &checkoutMessages.PickupPoint{ + Id: req.PickupPoint.Id, + Name: req.PickupPoint.Name, + }, + } + if _, err := s.applier.Apply(r.Context(), id, pp); err != nil { + writeError(w, http.StatusInternalServerError, "failed to set pickup point: "+err.Error()) + return + } + } + + // Apply contact details. + if req.Contact != nil { + msg := &checkoutMessages.ContactDetailsUpdated{ + Email: req.Contact.Email, + Phone: req.Contact.Phone, + Name: req.Contact.Name, + PostalCode: req.Contact.PostalCode, + } + if _, err := s.applier.Apply(r.Context(), id, msg); err != nil { + writeError(w, http.StatusInternalServerError, "failed to update contact details: "+err.Error()) + return + } + } + + g, err := s.applier.Get(r.Context(), id) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to read checkout: "+err.Error()) + return + } + + writeJSON(w, http.StatusOK, checkoutGrainToResponse(r.PathValue("id"), g)) +} + +// handleCompleteCheckout handles POST /checkout-sessions/{id}/complete. +// +// Completes the checkout session and creates an order. When an OrderApplier +// is configured on the server, a real order is created by calling the order +// service. Otherwise, a simplified order reference is generated locally. +func (s *CheckoutServer) handleCompleteCheckout(w http.ResponseWriter, r *http.Request) { + id, ok := parseSessionID(r) + if !ok { + writeError(w, http.StatusBadRequest, "invalid checkout session id") + return + } + + var req CompleteCheckoutRequest + json.NewDecoder(r.Body).Decode(&req) // best-effort + + g, err := s.applier.Get(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, "checkout session not found") + return + } + + // Default currency/country from the request or fall back to cart/cart state. + currency := req.Currency + country := req.Country + if currency == "" && g.CartState != nil { + currency = g.CartState.Currency + } + if currency == "" { + currency = "SEK" + } + + var orderID string + + if s.orderSvc != nil { + // Real order creation via the configured OrderApplier. + orderID, err = s.orderSvc.CreateOrder(r.Context(), id, g, req.Provider, req.PaymentToken, currency, country) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create order: "+err.Error()) + return + } + } else { + // Fallback: generate a local order reference (simplified). + var ref cart.CartId + ref, err = cart.NewCartId() + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to generate order reference") + return + } + orderID = ref.String() + } + + // Record the order on the checkout grain. + if _, err := s.applier.Apply(r.Context(), id, + &checkoutMessages.OrderCreated{ + OrderId: orderID, + CreatedAt: timestamppb.New(time.Now()), + }, + ); err != nil { + writeError(w, http.StatusInternalServerError, "failed to record order: "+err.Error()) + return + } + + g, err = s.applier.Get(r.Context(), id) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to read checkout: "+err.Error()) + return + } + + writeJSON(w, http.StatusOK, checkoutGrainToResponse(r.PathValue("id"), g)) +} + +// handleCancelCheckout handles POST /checkout-sessions/{id}/cancel. +// +// Cancels the checkout session. +func (s *CheckoutServer) handleCancelCheckout(w http.ResponseWriter, r *http.Request) { + id, ok := parseSessionID(r) + if !ok { + writeError(w, http.StatusBadRequest, "invalid checkout session id") + return + } + + // Cancel all active payments. + for { + g, err := s.applier.Get(r.Context(), id) + if err != nil { + break + } + openPayments := g.OpenPayments() + if len(openPayments) == 0 { + break + } + for _, p := range openPayments { + s.applier.Apply(r.Context(), id, &checkoutMessages.CancelPayment{ + PaymentId: p.PaymentId, + CancelledAt: timestamppb.New(time.Now()), + }) + } + } + + g, err := s.applier.Get(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, "checkout session not found") + return + } + writeJSON(w, http.StatusOK, checkoutGrainToResponse(r.PathValue("id"), g)) +} + +// --------------------------------------------------------------------------- +// OrderHTTPClient — an OrderApplier backed by the order service HTTP API +// --------------------------------------------------------------------------- + +// OrderHTTPClient implements OrderApplier by POSTing to an order service's +// /api/orders/from-checkout endpoint. It mirrors the existing -> order handoff +// used by the checkout service's own Klarna/Adyen callbacks. +type OrderHTTPClient struct { + baseURL string + httpClient *http.Client +} + +// NewOrderHTTPClient returns an order applier that creates orders by calling +// the order service at baseURL (e.g. "http://order-service:8092"). +func NewOrderHTTPClient(baseURL string) *OrderHTTPClient { + return &OrderHTTPClient{ + baseURL: baseURL, + httpClient: &http.Client{Timeout: 30 * time.Second}, + } +} + +// fromCheckoutReq mirrors cmd/order/handlers_checkout.go's internal type. +type fromCheckoutReq struct { + CheckoutId string `json:"checkoutId"` + IdempotencyKey string `json:"idempotencyKey"` + CartId string `json:"cartId"` + Currency string `json:"currency"` + Country string `json:"country"` + CustomerEmail string `json:"customerEmail,omitempty"` + CustomerName string `json:"customerName,omitempty"` + Lines []orderLine `json:"lines"` + Payment orderPayment `json:"payment"` +} + +type orderLine struct { + Reference string `json:"reference"` + Sku string `json:"sku"` + Name string `json:"name"` + Quantity int32 `json:"quantity"` + UnitPrice int64 `json:"unitPrice"` + TaxRate int32 `json:"taxRate"` +} + +type orderPayment struct { + Provider string `json:"provider"` + Reference string `json:"reference"` + Amount int64 `json:"amount"` +} + +// CreateOrder implements OrderApplier. It builds a from-checkout request from +// the checkout grain's state and POSTs it to the order service. +func (c *OrderHTTPClient) CreateOrder(ctx context.Context, checkoutID uint64, g *checkout.CheckoutGrain, provider, reference, currency, country string) (string, error) { + if g.CartState == nil { + return "", fmt.Errorf("order: checkout %d has no cart state", checkoutID) + } + + var customerEmail, customerName string + if g.ContactDetails != nil { + if g.ContactDetails.Email != nil { + customerEmail = *g.ContactDetails.Email + } + if g.ContactDetails.Name != nil { + customerName = *g.ContactDetails.Name + } + } + + // Compute total from cart items + deliveries. + var totalAmount int64 + lines := buildOrderLines(g) + for _, l := range lines { + totalAmount += l.UnitPrice * int64(l.Quantity) + } + + req := fromCheckoutReq{ + CheckoutId: fmt.Sprintf("%d", checkoutID), + IdempotencyKey: fmt.Sprintf("ucp-checkout-%d", checkoutID), + CartId: g.CartId.String(), + Currency: currency, + Country: country, + CustomerEmail: customerEmail, + CustomerName: customerName, + Lines: lines, + Payment: orderPayment{ + Provider: provider, + Reference: reference, + Amount: totalAmount, + }, + } + + body, err := json.Marshal(req) + if err != nil { + return "", fmt.Errorf("order: marshal: %w", err) + } + + url := c.baseURL + "/api/orders/from-checkout" + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("order: new request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return "", fmt.Errorf("order: do: %w", err) + } + defer resp.Body.Close() + + // Both 201 and 409 (idempotent hit) are valid. + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusConflict { + return "", fmt.Errorf("order: %s", resp.Status) + } + + var result struct { + OrderId string `json:"orderId"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("order: decode: %w", err) + } + if result.OrderId == "" { + return "", fmt.Errorf("order: empty orderId in response") + } + return result.OrderId, nil +} + +// buildOrderLines converts a checkout grain's cart items and delivery selections +// into order lines, matching the format expected by the order service. +func buildOrderLines(g *checkout.CheckoutGrain) []orderLine { + lines := make([]orderLine, 0, len(g.CartState.Items)+len(g.Deliveries)) + for _, it := range g.CartState.Items { + if it == nil { + continue + } + name := "" + if it.Meta != nil { + name = it.Meta.Name + } + lines = append(lines, orderLine{ + Reference: it.Sku, + Sku: it.Sku, + Name: name, + Quantity: int32(it.Quantity), + UnitPrice: it.Price.IncVat, + TaxRate: int32(it.Tax), + }) + } + for _, d := range g.Deliveries { + if d == nil || d.Price.IncVat <= 0 { + continue + } + lines = append(lines, orderLine{ + Reference: d.Provider, + Sku: d.Provider, + Name: "Delivery", + Quantity: 1, + UnitPrice: d.Price.IncVat, + TaxRate: 2500, + }) + } + return lines +} + +// --------------------------------------------------------------------------- +// Helper: parse session id from path +// --------------------------------------------------------------------------- + +func parseSessionID(r *http.Request) (uint64, bool) { + raw := r.PathValue("id") + if raw == "" { + return 0, false + } + cid, ok := cart.ParseCartId(raw) + if !ok { + return 0, false + } + return uint64(cid), true +} + +// --------------------------------------------------------------------------- +// Helper: convert CheckoutGrain → UCP CheckoutResponse +// --------------------------------------------------------------------------- + +func checkoutGrainToResponse(id string, g *checkout.CheckoutGrain) CheckoutResponse { + resp := CheckoutResponse{ + Id: id, + CartId: g.CartId.String(), + Status: "active", + Deliveries: make([]DeliveryResponse, 0, len(g.Deliveries)), + Payments: make([]PaymentResponse, 0, len(g.Payments)), + Totals: Totals{Currency: "SEK"}, + } + + if g.OrderId != nil && *g.OrderId != "" { + resp.OrderId = g.OrderId + resp.Status = "completed" + } + + if g.CartState != nil { + for _, it := range g.CartState.Items { + if it == nil { + continue + } + item := CartItem{ + Sku: it.Sku, + Quantity: int(it.Quantity), + UnitPrice: it.Price.IncVat, + TotalPrice: it.TotalPrice.IncVat, + TaxRate: it.Tax, + } + if it.Meta != nil { + item.Name = it.Meta.Name + item.Image = it.Meta.Image + } + resp.Items = append(resp.Items, item) + } + if g.CartState.TotalPrice != nil { + resp.Totals.TotalIncVat = g.CartState.TotalPrice.IncVat + resp.Totals.TotalExVat = g.CartState.TotalPrice.ValueExVat() + resp.Totals.TotalVat = g.CartState.TotalPrice.TotalVat() + } + } + + for _, d := range g.Deliveries { + if d == nil { + continue + } + dr := DeliveryResponse{ + Id: d.Id, + Provider: d.Provider, + Price: d.Price.IncVat, + Items: d.Items, + } + if d.PickupPoint != nil { + dr.PickupPoint = &PickupPointResp{ + Id: d.PickupPoint.Id, + Name: d.PickupPoint.Name, + Address: d.PickupPoint.Address, + City: d.PickupPoint.City, + Zip: d.PickupPoint.Zip, + } + } + resp.Deliveries = append(resp.Deliveries, dr) + } + + if g.ContactDetails != nil { + resp.Contact = &ContactResponse{ + Email: g.ContactDetails.Email, + Phone: g.ContactDetails.Phone, + Name: g.ContactDetails.Name, + PostalCode: g.ContactDetails.PostalCode, + } + } + + for _, p := range g.Payments { + if p == nil { + continue + } + resp.Payments = append(resp.Payments, PaymentResponse{ + Id: p.PaymentId, + Provider: p.Provider, + Amount: p.Amount, + Currency: p.Currency, + Status: string(p.Status), + }) + } + + return resp +} diff --git a/internal/ucp/handler.go b/internal/ucp/handler.go new file mode 100644 index 0000000..0a5e90a --- /dev/null +++ b/internal/ucp/handler.go @@ -0,0 +1,117 @@ +package ucp + +import "net/http" + +// --------------------------------------------------------------------------- +// Order HTTP handler mounting +// --------------------------------------------------------------------------- + +// OrderHandler returns an http.Handler that routes UCP order REST endpoints. +// Mount it under any prefix (e.g. /ucp/v1) in a ServeMux: +// +// mux.Handle("/ucp/v1/orders", ucp.OrderHandler(pool)) +// mux.Handle("/ucp/v1/orders/", ucp.OrderHandler(pool)) +// +// To sign responses with RFC 9421 HTTP Message Signatures, wrap with WithSigning: +// +// mux.Handle("/ucp/v1/orders", ucp.WithSigning(ucp.OrderHandler(pool), signer)) +func OrderHandler(applier OrderReadApplier) http.Handler { + s := NewOrderServer(applier) + mux := http.NewServeMux() + mux.HandleFunc("GET /{id}", s.handleGetOrder) + mux.HandleFunc("POST /{id}/cancel", s.handleCancelOrder) + mux.HandleFunc("POST /{id}/fulfillments", s.handleCreateFulfillment) + mux.HandleFunc("POST /{id}/returns", s.handleRequestReturn) + mux.HandleFunc("POST /{id}/refunds", s.handleIssueRefund) + return mux +} + +// --------------------------------------------------------------------------- +// Cart HTTP handler mounting +// --------------------------------------------------------------------------- + +// CartHandler returns an http.Handler that routes UCP cart REST endpoints. +// Mount it under any prefix (e.g. /ucp/v1) in a ServeMux: +// +// mux.Handle("/ucp/v1/carts", ucp.CartHandler(pool)) +// mux.Handle("/ucp/v1/carts/", ucp.CartHandler(pool)) +// +// To sign responses with RFC 9421 HTTP Message Signatures, wrap with WithSigning: +// +// mux.Handle("/ucp/v1/carts", ucp.WithSigning(ucp.CartHandler(pool), signer)) +func CartHandler(applier CartApplier) http.Handler { + s := NewCartServer(applier) + mux := http.NewServeMux() + mux.HandleFunc("POST /", s.handleCreateCart) + mux.HandleFunc("GET /{id}", s.handleGetCart) + mux.HandleFunc("PUT /{id}", s.handleUpdateCart) + mux.HandleFunc("POST /{id}/cancel", s.handleCancelCart) + return mux +} + +// --------------------------------------------------------------------------- +// Checkout HTTP handler mounting +// --------------------------------------------------------------------------- + +// CheckoutHandler returns an http.Handler that routes UCP checkout endpoints. +// An optional OrderApplier can be provided for real order creation on complete. +// +// mux.Handle("/ucp/v1/checkout-sessions", ucp.CheckoutHandler(pool)) +// mux.Handle("/ucp/v1/checkout-sessions/", ucp.CheckoutHandler(pool, orderSvc)) +// +// To sign responses with RFC 9421 HTTP Message Signatures, wrap with WithSigning: +// +// mux.Handle("/ucp/v1/checkout-sessions", ucp.WithSigning(ucp.CheckoutHandler(pool), signer)) +func CheckoutHandler(applier CheckoutApplier, orderSvc ...OrderApplier) http.Handler { + s := NewCheckoutServer(applier, orderSvc...) + mux := http.NewServeMux() + mux.HandleFunc("POST /", s.handleCreateCheckout) + mux.HandleFunc("GET /{id}", s.handleGetCheckout) + mux.HandleFunc("PUT /{id}", s.handleUpdateCheckout) + mux.HandleFunc("POST /{id}/complete", s.handleCompleteCheckout) + mux.HandleFunc("POST /{id}/cancel", s.handleCancelCheckout) + return mux +} + +// --------------------------------------------------------------------------- +// Combined handler for mounting all UCP endpoints under one prefix +// --------------------------------------------------------------------------- + +// Options controls optional features of the combined handler. +type Options struct { + CheckoutApplier CheckoutApplier // when set, mounts /checkout-sessions + OrderApplier OrderApplier // optional order creation for complete endpoint +} + +// Handler returns an http.Handler that serves all mounted UCP endpoints under +// a single prefix. Usage: +// +// mux.Handle("/ucp/v1/", ucp.Handler(pool, ucp.Options{ +// CheckoutApplier: checkoutPool, +// })) +func Handler(cartApplier CartApplier, opts Options) http.Handler { + mux := http.NewServeMux() + + // Cart endpoints + cartSrv := NewCartServer(cartApplier) + mux.HandleFunc("POST /carts", cartSrv.handleCreateCart) + mux.HandleFunc("GET /carts/{id}", cartSrv.handleGetCart) + mux.HandleFunc("PUT /carts/{id}", cartSrv.handleUpdateCart) + mux.HandleFunc("POST /carts/{id}/cancel", cartSrv.handleCancelCart) + + // Checkout endpoints (optional) + if opts.CheckoutApplier != nil { + var orderOpts []OrderApplier + if opts.OrderApplier != nil { + orderOpts = []OrderApplier{opts.OrderApplier} + } + checkoutSrv := NewCheckoutServer(opts.CheckoutApplier, orderOpts...) + mux.HandleFunc("POST /checkout-sessions", checkoutSrv.handleCreateCheckout) + mux.HandleFunc("GET /checkout-sessions/{id}", checkoutSrv.handleGetCheckout) + mux.HandleFunc("PUT /checkout-sessions/{id}", checkoutSrv.handleUpdateCheckout) + mux.HandleFunc("POST /checkout-sessions/{id}/complete", checkoutSrv.handleCompleteCheckout) + mux.HandleFunc("POST /checkout-sessions/{id}/cancel", checkoutSrv.handleCancelCheckout) + } + + return mux +} diff --git a/internal/ucp/order.go b/internal/ucp/order.go new file mode 100644 index 0000000..f538865 --- /dev/null +++ b/internal/ucp/order.go @@ -0,0 +1,329 @@ +package ucp + +import ( + "encoding/json" + "net/http" + "time" + + "git.k6n.net/mats/go-cart-actor/pkg/actor" + "git.k6n.net/mats/go-cart-actor/pkg/order" + orderMessages "git.k6n.net/mats/go-cart-actor/proto/order" +) + +// OrderServer is the UCP REST adapter over the order grain pool. +type OrderServer struct { + applier OrderReadApplier +} + +// NewOrderServer builds a UCP REST adapter over an order grain pool. +func NewOrderServer(applier OrderReadApplier) *OrderServer { + return &OrderServer{applier: applier} +} + +// --------------------------------------------------------------------------- +// UCP Order endpoints +// --------------------------------------------------------------------------- + +// handleGetOrder handles GET /orders/{id}. +func (s *OrderServer) handleGetOrder(w http.ResponseWriter, r *http.Request) { + g, err := s.loadOrder(w, r) + if err != nil || g == nil { + return + } + writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), g)) +} + +// handleCancelOrder handles POST /orders/{id}/cancel. +func (s *OrderServer) handleCancelOrder(w http.ResponseWriter, r *http.Request) { + g, err := s.loadOrder(w, r) + if err != nil || g == nil { + return + } + var req struct { + Reason string `json:"reason,omitempty"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + + res, applyErr := s.applier.Apply(r.Context(), g.Id, &orderMessages.CancelOrder{ + Reason: req.Reason, + AtMs: time.Now().UnixMilli(), + }) + if applyErr != nil || res == nil || mutationRejected(res) { + msg := "failed to cancel order" + if res != nil && len(res.Mutations) > 0 && res.Mutations[0].Error != nil { + msg += ": " + res.Mutations[0].Error.Error() + } else if applyErr != nil { + msg += ": " + applyErr.Error() + } + writeError(w, http.StatusUnprocessableEntity, msg) + return + } + + updated, _ := s.applier.Get(r.Context(), g.Id) + if updated != nil { + writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), updated)) + } else { + writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), g)) + } +} + +// handleCreateFulfillment handles POST /orders/{id}/fulfillments. +func (s *OrderServer) handleCreateFulfillment(w http.ResponseWriter, r *http.Request) { + g, err := s.loadOrder(w, r) + if err != nil || g == nil { + return + } + + var req struct { + Carrier string `json:"carrier,omitempty"` + TrackingNumber string `json:"trackingNumber,omitempty"` + TrackingURI string `json:"trackingUri,omitempty"` + Lines []struct { + Reference string `json:"reference"` + Quantity int32 `json:"quantity"` + } `json:"lines,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + + fid, _ := order.NewOrderId() + msg := &orderMessages.CreateFulfillment{ + Id: "f_" + fid.String(), + Carrier: req.Carrier, + TrackingNumber: req.TrackingNumber, + TrackingUri: req.TrackingURI, + AtMs: time.Now().UnixMilli(), + } + for _, l := range req.Lines { + msg.Lines = append(msg.Lines, &orderMessages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity}) + } + + res, applyErr := s.applier.Apply(r.Context(), g.Id, msg) + if applyErr != nil || res == nil || mutationRejected(res) { + msg := "failed to create fulfillment" + if res != nil && len(res.Mutations) > 0 && res.Mutations[0].Error != nil { + msg += ": " + res.Mutations[0].Error.Error() + } else if applyErr != nil { + msg += ": " + applyErr.Error() + } + writeError(w, http.StatusUnprocessableEntity, msg) + return + } + + updated, _ := s.applier.Get(r.Context(), g.Id) + writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), updated)) +} + +// handleRequestReturn handles POST /orders/{id}/returns. +func (s *OrderServer) handleRequestReturn(w http.ResponseWriter, r *http.Request) { + g, err := s.loadOrder(w, r) + if err != nil || g == nil { + return + } + + var req struct { + Reason string `json:"reason,omitempty"` + Lines []struct { + Reference string `json:"reference"` + Quantity int32 `json:"quantity"` + } `json:"lines,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + + rid, _ := order.NewOrderId() + msg := &orderMessages.RequestReturn{ + Id: "r_" + rid.String(), + Reason: req.Reason, + AtMs: time.Now().UnixMilli(), + } + for _, l := range req.Lines { + msg.Lines = append(msg.Lines, &orderMessages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity}) + } + + res, applyErr := s.applier.Apply(r.Context(), g.Id, msg) + if applyErr != nil || res == nil || mutationRejected(res) { + msg := "failed to request return" + if res != nil && len(res.Mutations) > 0 && res.Mutations[0].Error != nil { + msg += ": " + res.Mutations[0].Error.Error() + } else if applyErr != nil { + msg += ": " + applyErr.Error() + } + writeError(w, http.StatusUnprocessableEntity, msg) + return + } + + updated, _ := s.applier.Get(r.Context(), g.Id) + writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), updated)) +} + +// handleIssueRefund handles POST /orders/{id}/refunds. +func (s *OrderServer) handleIssueRefund(w http.ResponseWriter, r *http.Request) { + g, err := s.loadOrder(w, r) + if err != nil || g == nil { + return + } + + var req struct { + Amount int64 `json:"amount,omitempty"` // minor units; omit for full remaining + } + _ = json.NewDecoder(r.Body).Decode(&req) + + amount := req.Amount + if amount <= 0 { + amount = g.CapturedAmount - g.RefundedAmount + } + if amount <= 0 { + writeError(w, http.StatusBadRequest, "no captured amount to refund") + return + } + + idStr := order.OrderId(g.Id).String() + res, applyErr := s.applier.Apply(r.Context(), g.Id, &orderMessages.IssueRefund{ + Provider: "ucp", + Amount: amount, + Reference: "ref-ucp-" + idStr, + AtMs: time.Now().UnixMilli(), + }) + if applyErr != nil || res == nil || mutationRejected(res) { + msg := "failed to issue refund" + if res != nil && len(res.Mutations) > 0 && res.Mutations[0].Error != nil { + msg += ": " + res.Mutations[0].Error.Error() + } else if applyErr != nil { + msg += ": " + applyErr.Error() + } + writeError(w, http.StatusUnprocessableEntity, msg) + return + } + + updated, _ := s.applier.Get(r.Context(), g.Id) + writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), updated)) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// mutationRejected returns true if the Apply result indicates the mutation was +// rejected (business-rule violation rather than infrastructure error). +func mutationRejected(res *actor.MutationResult[order.OrderGrain]) bool { + return len(res.Mutations) > 0 && res.Mutations[0].Error != nil +} + +// loadOrder parses the id from the path and returns the grain, or writes an +// error response and returns nil. +func (s *OrderServer) loadOrder(w http.ResponseWriter, r *http.Request) (*order.OrderGrain, error) { + raw := r.PathValue("id") + if raw == "" { + writeError(w, http.StatusBadRequest, "missing order id") + return nil, nil + } + id, ok := order.ParseOrderId(raw) + if !ok { + writeError(w, http.StatusBadRequest, "invalid order id") + return nil, nil + } + g, err := s.applier.Get(r.Context(), uint64(id)) + if err != nil { + writeError(w, http.StatusNotFound, "order not found") + return nil, nil + } + if g.Status == order.StatusNew { + writeError(w, http.StatusNotFound, "order not found") + return nil, nil + } + return g, nil +} + +// orderGrainToResponse converts an OrderGrain to an OrderResponse. +func orderGrainToResponse(id string, g *order.OrderGrain) OrderResponse { + resp := OrderResponse{ + OrderId: id, + Reference: g.OrderReference, + Status: string(g.Status), + Currency: g.Currency, + TotalAmount: g.TotalAmount, + TotalTax: g.TotalTax, + Lines: make([]OrderLineResp, 0, len(g.Lines)), + Payments: make([]OrderPaymentResp, 0, len(g.Payments)), + Fulfillments: make([]OrderFulfillmentResp, 0, len(g.Fulfillments)), + Returns: make([]OrderReturnResp, 0, len(g.Returns)), + Refunds: make([]OrderRefundResp, 0, len(g.Refunds)), + } + + for _, l := range g.Lines { + resp.Lines = append(resp.Lines, OrderLineResp{ + Reference: l.Reference, + Sku: l.Sku, + Name: l.Name, + Quantity: l.Quantity, + UnitPrice: l.UnitPrice, + TaxRate: l.TaxRate, + TotalAmount: l.TotalAmount, + TotalTax: l.TotalTax, + Fulfilled: l.Fulfilled, + }) + } + + for _, p := range g.Payments { + if p == nil { + continue + } + resp.Payments = append(resp.Payments, OrderPaymentResp{ + Provider: p.Provider, + Authorized: p.Authorized, + Captured: p.Captured, + Refunded: p.Refunded, + AuthRef: p.AuthRef, + CaptureRef: p.CaptureRef, + }) + } + + for _, f := range g.Fulfillments { + lines := make([]OrderLineEntry, 0, len(f.Lines)) + for _, l := range f.Lines { + lines = append(lines, OrderLineEntry{Reference: l.Reference, Quantity: l.Quantity}) + } + resp.Fulfillments = append(resp.Fulfillments, OrderFulfillmentResp{ + Id: f.ID, + Carrier: f.Carrier, + TrackingNumber: f.TrackingNumber, + TrackingURI: f.TrackingURI, + Lines: lines, + }) + } + + for _, r := range g.Returns { + lines := make([]OrderLineEntry, 0, len(r.Lines)) + for _, l := range r.Lines { + lines = append(lines, OrderLineEntry{Reference: l.Reference, Quantity: l.Quantity}) + } + resp.Returns = append(resp.Returns, OrderReturnResp{ + Id: r.ID, + Reason: r.Reason, + Lines: lines, + }) + } + + for _, r := range g.Refunds { + resp.Refunds = append(resp.Refunds, OrderRefundResp{ + Provider: r.Provider, + Amount: r.Amount, + Reference: r.Reference, + }) + } + + resp.CapturedAmount = g.CapturedAmount + resp.RefundedAmount = g.RefundedAmount + resp.CustomerEmail = g.CustomerEmail + resp.CustomerName = g.CustomerName + resp.CartId = g.CartId + resp.PlacedAt = g.PlacedAt + resp.UpdatedAt = g.UpdatedAt + + return resp +} diff --git a/internal/ucp/order_test.go b/internal/ucp/order_test.go new file mode 100644 index 0000000..6eae3f2 --- /dev/null +++ b/internal/ucp/order_test.go @@ -0,0 +1,300 @@ +package ucp + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.k6n.net/mats/go-cart-actor/pkg/actor" + "git.k6n.net/mats/go-cart-actor/pkg/order" + messages "git.k6n.net/mats/go-cart-actor/proto/order" + "google.golang.org/protobuf/proto" +) + +// testOrderApplier implements OrderReadApplier with an in-memory map of grains. +type testOrderApplier struct { + grains map[uint64]*order.OrderGrain +} + +func newTestOrderApplier() *testOrderApplier { + return &testOrderApplier{ + grains: make(map[uint64]*order.OrderGrain), + } +} + +func (a *testOrderApplier) Get(_ context.Context, id uint64) (*order.OrderGrain, error) { + g, ok := a.grains[id] + if !ok { + // Auto-create so non-existent orders return StatusNew (expected 404). + g = order.NewOrderGrain(id, time.UnixMilli(1000000)) + a.grains[id] = g + } + return g, nil +} + +func (a *testOrderApplier) Apply(_ context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[order.OrderGrain], error) { + g, err := a.Get(nil, id) + if err != nil { + return nil, err + } + results := make([]actor.ApplyResult, len(msgs)) + for i, msg := range msgs { + results[i] = actor.ApplyResult{Type: string(msg.ProtoReflect().Descriptor().FullName())} + switch m := msg.(type) { + case *messages.CancelOrder: + if g.Status != order.StatusPending && g.Status != order.StatusAuthorized { + results[i].Error = errRejected("cannot cancel in status " + string(g.Status)) + break + } + g.Status = order.StatusCancelled + g.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + case *messages.CreateFulfillment: + if g.Status != order.StatusCaptured && g.Status != order.StatusPartiallyFulfilled { + results[i].Error = errRejected("cannot fulfill in status " + string(g.Status)) + break + } + fulfillment := order.Fulfillment{ + ID: m.Id, + Carrier: m.Carrier, + TrackingNumber: m.TrackingNumber, + TrackingURI: m.TrackingUri, + } + for _, fl := range m.Lines { + fulfillment.Lines = append(fulfillment.Lines, order.FulfillmentEntry{ + Reference: fl.Reference, + Quantity: int(fl.Quantity), + }) + } + g.Fulfillments = append(g.Fulfillments, fulfillment) + g.Status = order.StatusFulfilled + case *messages.RequestReturn: + ret := order.Return{ + ID: m.Id, + Reason: m.Reason, + } + for _, fl := range m.Lines { + ret.Lines = append(ret.Lines, order.FulfillmentEntry{ + Reference: fl.Reference, + Quantity: int(fl.Quantity), + }) + } + g.Returns = append(g.Returns, ret) + case *messages.IssueRefund: + g.Refunds = append(g.Refunds, order.Refund{ + Provider: m.Provider, + Amount: m.Amount, + Reference: m.Reference, + }) + g.RefundedAmount += m.Amount + if g.RefundedAmount >= g.CapturedAmount { + g.Status = order.StatusRefunded + } + } + } + return &actor.MutationResult[order.OrderGrain]{ + Result: *g, + Mutations: results, + }, nil +} + +func errRejected(msg string) *actor.MutationError { + return &actor.MutationError{Message: msg} +} + +// seedOrder places an order in the test applier with a given status. +func seedOrder(a *testOrderApplier, status order.Status) string { + id, _ := order.NewOrderId() + g, _ := a.Get(nil, uint64(id)) + g.Status = status + g.Currency = "SEK" + g.TotalAmount = 10000 + g.TotalTax = 2000 + g.Lines = []order.Line{ + {Reference: "test-1", Sku: "test-1", Name: "Test Item", Quantity: 2, UnitPrice: 5000, TaxRate: 2500, TotalAmount: 10000, TotalTax: 2000}, + } + if status == order.StatusCaptured { + g.CapturedAmount = 10000 + } + return id.String() +} + +func TestOrderHandler_GetOrder(t *testing.T) { + a := newTestOrderApplier() + h := OrderHandler(a) + oid := seedOrder(a, order.StatusCaptured) + + req := httptest.NewRequest("GET", "/"+oid, nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp OrderResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.OrderId != oid { + t.Fatalf("expected orderId %q, got %q", oid, resp.OrderId) + } + if resp.Status != "captured" { + t.Fatalf("expected status 'captured', got %q", resp.Status) + } + if len(resp.Lines) != 1 { + t.Fatalf("expected 1 line, got %d", len(resp.Lines)) + } + if resp.Lines[0].Sku != "test-1" { + t.Fatalf("expected sku 'test-1', got %q", resp.Lines[0].Sku) + } +} + +func TestOrderHandler_GetOrderNotFound(t *testing.T) { + a := newTestOrderApplier() + h := OrderHandler(a) + + // Non-existent order (not seeded) returns 404. + req := httptest.NewRequest("GET", "/nonexistent", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404 for non-existent order (StatusNew -> 404), got %d", rec.Code) + } +} + +func TestOrderHandler_CancelOrder(t *testing.T) { + a := newTestOrderApplier() + h := OrderHandler(a) + oid := seedOrder(a, order.StatusAuthorized) + + body := `{"reason": "customer request"}` + req := httptest.NewRequest("POST", "/"+oid+"/cancel", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp OrderResponse + json.NewDecoder(rec.Body).Decode(&resp) + if resp.Status != "cancelled" { + t.Fatalf("expected status 'cancelled', got %q", resp.Status) + } +} + +func TestOrderHandler_CancelOrder_IllegalState(t *testing.T) { + a := newTestOrderApplier() + h := OrderHandler(a) + oid := seedOrder(a, order.StatusFulfilled) // can't cancel from fulfilled + + req := httptest.NewRequest("POST", "/"+oid+"/cancel", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected 422 for illegal transition, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestOrderHandler_CreateFulfillment(t *testing.T) { + a := newTestOrderApplier() + h := OrderHandler(a) + oid := seedOrder(a, order.StatusCaptured) + + body := `{"carrier":"PostNord","trackingNumber":"ABC123","lines":[{"reference":"test-1","quantity":2}]}` + req := httptest.NewRequest("POST", "/"+oid+"/fulfillments", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp OrderResponse + json.NewDecoder(rec.Body).Decode(&resp) + if len(resp.Fulfillments) != 1 { + t.Fatalf("expected 1 fulfillment, got %d", len(resp.Fulfillments)) + } + if resp.Fulfillments[0].Carrier != "PostNord" { + t.Fatalf("expected carrier 'PostNord', got %q", resp.Fulfillments[0].Carrier) + } + if resp.Fulfillments[0].TrackingNumber != "ABC123" { + t.Fatalf("expected tracking 'ABC123', got %q", resp.Fulfillments[0].TrackingNumber) + } +} + +func TestOrderHandler_RequestReturn(t *testing.T) { + a := newTestOrderApplier() + h := OrderHandler(a) + oid := seedOrder(a, order.StatusFulfilled) + + body := `{"reason":"defective","lines":[{"reference":"test-1","quantity":1}]}` + req := httptest.NewRequest("POST", "/"+oid+"/returns", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp OrderResponse + json.NewDecoder(rec.Body).Decode(&resp) + if len(resp.Returns) != 1 { + t.Fatalf("expected 1 return, got %d", len(resp.Returns)) + } + if resp.Returns[0].Reason != "defective" { + t.Fatalf("expected reason 'defective', got %q", resp.Returns[0].Reason) + } +} + +func TestOrderHandler_IssueRefund(t *testing.T) { + a := newTestOrderApplier() + h := OrderHandler(a) + oid := seedOrder(a, order.StatusCaptured) + + body := `{"amount":3000}` + req := httptest.NewRequest("POST", "/"+oid+"/refunds", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp OrderResponse + json.NewDecoder(rec.Body).Decode(&resp) + if len(resp.Refunds) != 1 { + t.Fatalf("expected 1 refund, got %d", len(resp.Refunds)) + } + if resp.Refunds[0].Amount != 3000 { + t.Fatalf("expected amount 3000, got %d", resp.Refunds[0].Amount) + } +} + +func TestOrderHandler_SignatureIntegration(t *testing.T) { + cfg := mustLoadTestKey(t) + a := newTestOrderApplier() + oid := seedOrder(a, order.StatusCaptured) + + h := WithSigning(OrderHandler(a), cfg) + req := httptest.NewRequest("GET", "/"+oid, nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + if rec.Header().Get("Signature-Input") == "" { + t.Fatal("expected Signature-Input with signing enabled") + } +} diff --git a/internal/ucp/signing.go b/internal/ucp/signing.go new file mode 100644 index 0000000..9928dfd --- /dev/null +++ b/internal/ucp/signing.go @@ -0,0 +1,183 @@ +package ucp + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +// --------------------------------------------------------------------------- +// SigningConfig — ECDSA P-256 key for RFC 9421 HTTP Message Signatures +// --------------------------------------------------------------------------- + +// SigningConfig holds the ECDSA P-256 private key and key ID for signing UCP +// HTTP responses with RFC 9421 HTTP Message Signatures. The corresponding +// public key is published in the UCP profile (signing_keys) and served at +// /.well-known/ucp so any UCP platform can verify response authenticity. +type SigningConfig struct { + PrivateKey *ecdsa.PrivateKey + KeyID string // matches the kid in the UCP profile signing_keys +} + +// NewSigningConfigFromPEM parses a PEM-encoded ECDSA P-256 private key and +// returns a SigningConfig ready for use. +// +// data — PEM block containing an EC PRIVATE KEY (PKCS#8 or SEC1) +// keyID — the kid matched by the UCP profile signing_keys entry +func NewSigningConfigFromPEM(pemData []byte, keyID string) (*SigningConfig, error) { + block, _ := pem.Decode(pemData) + if block == nil { + return nil, fmt.Errorf("ucp signing: no PEM block found") + } + + var priv any + var err error + switch block.Type { + case "EC PRIVATE KEY": + // SEC1 format (openssl ecparam -genkey -name prime256v1) + priv, err = x509.ParseECPrivateKey(block.Bytes) + case "PRIVATE KEY": + // PKCS#8 format + priv, err = x509.ParsePKCS8PrivateKey(block.Bytes) + default: + return nil, fmt.Errorf("ucp signing: unsupported PEM type %q", block.Type) + } + if err != nil { + return nil, fmt.Errorf("ucp signing: parse key: %w", err) + } + + ecKey, ok := priv.(*ecdsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("ucp signing: key is not ECDSA") + } + if ecKey.Curve != elliptic.P256() { + return nil, fmt.Errorf("ucp signing: key is not P-256 (got %s)", ecKey.Curve.Params().Name) + } + + return &SigningConfig{PrivateKey: ecKey, KeyID: keyID}, nil +} + +// --------------------------------------------------------------------------- +// WithSigning — HTTP middleware wrapper for RFC 9421 response signing +// --------------------------------------------------------------------------- + +// WithSigning wraps h with an HTTP middleware that adds RFC 9421 HTTP Message +// Signatures to every response. The Signature-Input and Signature headers are +// computed over @status, content-type, and x-ucp-timestamp, signed with the +// configured ECDSA P-256 key. +// +// Mount it on any existing UCP handler: +// +// mux.Handle("/ucp/v1/carts", ucp.WithSigning(ucp.CartHandler(pool), cfg)) +func WithSigning(h http.Handler, cfg *SigningConfig) http.Handler { + if cfg == nil || cfg.PrivateKey == nil { + return h // pass through without signing + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sw := &signedWriter{ + ResponseWriter: w, + cfg: cfg, + } + h.ServeHTTP(sw, r) + }) +} + +// --------------------------------------------------------------------------- +// signedWriter — response interceptor that adds signature headers +// --------------------------------------------------------------------------- + +type signedWriter struct { + http.ResponseWriter + cfg *SigningConfig + statusCode int + wroteHeader bool +} + +func (w *signedWriter) WriteHeader(statusCode int) { + if w.wroteHeader { + return + } + w.wroteHeader = true + w.statusCode = statusCode + + created := time.Now().Unix() + w.ResponseWriter.Header().Set("x-ucp-timestamp", strconv.FormatInt(created, 10)) + + // Add signature headers before flushing. + w.addSignatureHeaders(created) + + w.ResponseWriter.WriteHeader(statusCode) +} + +func (w *signedWriter) Write(p []byte) (int, error) { + if !w.wroteHeader { + w.WriteHeader(http.StatusOK) + } + return w.ResponseWriter.Write(p) +} + +// addSignatureHeaders computes and writes the Signature-Input and Signature +// headers per RFC 9421 §3.2 / §3.3. +func (w *signedWriter) addSignatureHeaders(created int64) { + contentType := w.ResponseWriter.Header().Get("Content-Type") + sigTimestamp := strconv.FormatInt(created, 10) + + // Covered components (in order). RFC 9421 §3.2: derived component names + // (@status) are bare identifiers; HTTP header field names are sf-strings + // and MUST be quoted. + components := []string{ + "@status", + `"content-type"`, + `"x-ucp-timestamp"`, + } + + // Build the signature base string (RFC 9421 §2.2). + var base strings.Builder + base.WriteString(fmt.Sprintf(`"@status": %d`, w.statusCode)) + base.WriteByte('\n') + base.WriteString(fmt.Sprintf(`"content-type": %s`, contentType)) + base.WriteByte('\n') + base.WriteString(fmt.Sprintf(`"x-ucp-timestamp": %s`, sigTimestamp)) + base.WriteByte('\n') + + // Append the signature-params pseudo-line (RFC 9421 §2.2). + compList := strings.Join(components, " ") + base.WriteString(fmt.Sprintf(`"@signature-params": (%s);created=%d;keyid=%q;alg="ecdsa-p256"`, + compList, created, w.cfg.KeyID)) + + // Sign the SHA-256 digest. + digest := sha256.Sum256([]byte(base.String())) + r, s, err := ecdsa.Sign(rand.Reader, w.cfg.PrivateKey, digest[:]) + if err != nil { + // Fail open — response is sent without signature headers. + return + } + + // ECDSA P-256 signature raw bytes: r||s concatenation, each 32 bytes. + rBytes := r.Bytes() + sBytes := s.Bytes() + rawSig := make([]byte, 64) + copy(rawSig[32-len(rBytes):32], rBytes) + copy(rawSig[64-len(sBytes):64], sBytes) + + sigB64 := base64.RawURLEncoding.EncodeToString(rawSig) + + // Signature-Input: sig1=(components);created=N;keyid="...";alg="ecdsa-p256" + sigInput := fmt.Sprintf(`sig1=(%s);created=%d;keyid=%q;alg="ecdsa-p256"`, + compList, created, w.cfg.KeyID) + + // Signature: sig1=:base64url: + sigValue := fmt.Sprintf("sig1=:%s:", sigB64) + + w.ResponseWriter.Header().Set("Signature-Input", sigInput) + w.ResponseWriter.Header().Set("Signature", sigValue) +} diff --git a/internal/ucp/signing_test.go b/internal/ucp/signing_test.go new file mode 100644 index 0000000..0235af6 --- /dev/null +++ b/internal/ucp/signing_test.go @@ -0,0 +1,173 @@ +package ucp + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSigningConfig_FromPEM(t *testing.T) { + // PEM from the generated key pair (openssl ecparam -genkey -name prime256v1) + pemData := `-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIDTssEctnrEqym80fx2jEVolTW+tWrHyo8fmbi8hMFWmoAoGCCqGSM49 +AwEHoUQDQgAEpnPksDSSMcNgUYT4++Z6XuS0V2p6TNMSjWBsgXOu7mo5Esoux+Cm +y+C84ty5VUAdEpoDyNfWMeaAlHKGo+FjiQ== +-----END EC PRIVATE KEY-----` + + cfg, err := NewSigningConfigFromPEM([]byte(pemData), "k6n-ecdsa-2026") + if err != nil { + t.Fatalf("NewSigningConfigFromPEM failed: %v", err) + } + if cfg.KeyID != "k6n-ecdsa-2026" { + t.Fatalf("expected key ID 'k6n-ecdsa-2026', got %q", cfg.KeyID) + } + if cfg.PrivateKey == nil { + t.Fatal("expected non-nil private key") + } + if cfg.PrivateKey.Curve.Params().Name != "P-256" { + t.Fatalf("expected P-256 curve, got %s", cfg.PrivateKey.Curve.Params().Name) + } +} + +func TestSigningConfig_InvalidPEM(t *testing.T) { + _, err := NewSigningConfigFromPEM([]byte("not a pem"), "test") + if err == nil { + t.Fatal("expected error for invalid PEM") + } +} + +func TestWithSigning_AddsHeaders(t *testing.T) { + // Load the test key. + cfg := mustLoadTestKey(t) + + // A simple handler that returns a JSON response. + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + }) + + wrapped := WithSigning(handler, cfg) + req := httptest.NewRequest("GET", "/test", nil) + rec := httptest.NewRecorder() + wrapped.ServeHTTP(rec, req) + + // Check the response body. + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + + // Check that signature headers are present. + sigInput := rec.Header().Get("Signature-Input") + sig := rec.Header().Get("Signature") + ts := rec.Header().Get("x-ucp-timestamp") + + if sigInput == "" { + t.Fatal("expected Signature-Input header") + } + if sig == "" { + t.Fatal("expected Signature header") + } + if ts == "" { + t.Fatal("expected x-ucp-timestamp header") + } + + // Verify the signature input format. + if !strings.HasPrefix(sigInput, `sig1=(@status "content-type" "x-ucp-timestamp");`) { + t.Fatalf("unexpected Signature-Input format: %q", sigInput) + } + if !strings.Contains(sigInput, `keyid="k6n-ecdsa-2026"`) { + t.Fatalf("Signature-Input missing keyid: %q", sigInput) + } + if !strings.Contains(sigInput, `alg="ecdsa-p256"`) { + t.Fatalf("Signature-Input missing alg: %q", sigInput) + } + + // Verify the signature format. + if !strings.HasPrefix(sig, "sig1=:") || !strings.HasSuffix(sig, ":") { + t.Fatalf("unexpected Signature format: %q", sig) + } +} + +func TestWithSigning_NilConfig(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + }) + + wrapped := WithSigning(handler, nil) + req := httptest.NewRequest("GET", "/test", nil) + rec := httptest.NewRecorder() + wrapped.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + if rec.Header().Get("Signature-Input") != "" { + t.Fatal("expected no Signature-Input when signing is nil") + } +} + +func TestWithSigning_DifferentStatuses(t *testing.T) { + cfg := mustLoadTestKey(t) + + for _, status := range []int{200, 201, 400, 404, 500} { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + w.Write([]byte(`{}`)) + }) + + wrapped := WithSigning(handler, cfg) + req := httptest.NewRequest("GET", "/test", nil) + rec := httptest.NewRecorder() + wrapped.ServeHTTP(rec, req) + + if rec.Code != status { + t.Fatalf("expected status %d, got %d", status, rec.Code) + } + if rec.Header().Get("Signature-Input") == "" { + t.Fatalf("expected Signature-Input for status %d", status) + } + } +} + +func TestWithSigning_WithUCPCartHandler(t *testing.T) { + cfg := mustLoadTestKey(t) + applier := newTestApplier() + handler := CartHandler(applier) + wrapped := WithSigning(handler, cfg) + + // Create a cart through the wrapped handler. + req := httptest.NewRequest("POST", "/", nil) + rec := httptest.NewRecorder() + wrapped.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d", rec.Code) + } + + // Verify signed. + if rec.Header().Get("Signature-Input") == "" { + t.Fatal("expected Signature-Input on UCP cart response") + } + if rec.Header().Get("x-ucp-timestamp") == "" { + t.Fatal("expected x-ucp-timestamp on UCP cart response") + } +} + +// mustLoadTestKey loads the test PEM for signing tests. +func mustLoadTestKey(t testing.TB) *SigningConfig { + t.Helper() + pemData := `-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIDTssEctnrEqym80fx2jEVolTW+tWrHyo8fmbi8hMFWmoAoGCCqGSM49 +AwEHoUQDQgAEpnPksDSSMcNgUYT4++Z6XuS0V2p6TNMSjWBsgXOu7mo5Esoux+Cm +y+C84ty5VUAdEpoDyNfWMeaAlHKGo+FjiQ== +-----END EC PRIVATE KEY-----` + cfg, err := NewSigningConfigFromPEM([]byte(pemData), "k6n-ecdsa-2026") + if err != nil { + t.Fatalf("mustLoadTestKey: %v", err) + } + return cfg +} diff --git a/internal/ucp/types.go b/internal/ucp/types.go new file mode 100644 index 0000000..57a5c6b --- /dev/null +++ b/internal/ucp/types.go @@ -0,0 +1,397 @@ +// Package ucp provides a Universal Commerce Protocol (UCP) REST adapter for +// the cart and checkout grain pools. It translates UCP-standard cart/checkout +// endpoints (POST /carts, GET /carts/{id}, POST /checkout-sessions, …) into +// grain pool Get/Apply calls, so any UCP-compatible platform or AI agent can +// interact with the business without a bespoke integration. +// +// Transport: REST (JSON over HTTP, following the UCP Shopping Service spec). +// +// Current capabilities: +// - Cart: create, read, update items, cancel/clear +// - Checkout: initiate session, read, update, complete, cancel +// +// Mount on any existing HTTP mux via ucp.NewCartHandler(…) and +// ucp.NewCheckoutHandler(…). +package ucp + +import ( + "context" + "encoding/json" + "net/http" + + "git.k6n.net/mats/go-cart-actor/pkg/cart" + "git.k6n.net/mats/go-cart-actor/pkg/checkout" + "git.k6n.net/mats/go-cart-actor/pkg/order" + "git.k6n.net/mats/go-cart-actor/pkg/actor" + "google.golang.org/protobuf/proto" +) + +// --------------------------------------------------------------------------- +// Applier interfaces (same pattern as the MCP packages) +// --------------------------------------------------------------------------- + +// CartApplier is the minimal grain-pool interface the UCP cart adapter needs. +type CartApplier interface { + Get(ctx context.Context, id uint64) (*cart.CartGrain, error) + Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[cart.CartGrain], error) +} + +// CheckoutApplier is the minimal grain-pool interface the UCP checkout adapter needs. +type CheckoutApplier interface { + Get(ctx context.Context, id uint64) (*checkout.CheckoutGrain, error) + Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[checkout.CheckoutGrain], error) +} + +// OrderReadApplier is the minimal grain-pool interface the UCP order adapter +// needs to read and mutate order grains. +type OrderReadApplier interface { + Get(ctx context.Context, id uint64) (*order.OrderGrain, error) + Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[order.OrderGrain], error) +} + +// OrderApplier is the interface the UCP checkout adapter uses to create a real +// order from a completed checkout session. It abstracts over the transport +// (direct grain pool, HTTP, etc.) so the adapter is portable. +// +// The checkout adapter calls CreateOrder in handleCompleteCheckout when the +// optional order applier is configured. Without it, the endpoint falls back to +// a simplified no-op order reference. +type OrderApplier interface { + // CreateOrder creates a real order from the checkout session after payment + // has been completed. It returns the order ID string. + // + // checkoutID — the checkout grain's numeric id + // g — the checkout grain's full state (items, deliveries, contact, …) + // provider — payment provider that settled the payment (e.g. "adyen", "klarna") + // reference — PSP / payment reference + // currency — ISO 4217 currency code (e.g. "SEK") + // country — ISO 3166-1 alpha-2 country code (e.g. "SE") + CreateOrder(ctx context.Context, checkoutID uint64, g *checkout.CheckoutGrain, provider, reference, currency, country string) (string, error) +} + +// --------------------------------------------------------------------------- +// UCP Cart types (mapped from the UCP Shopping Service REST spec) +// --------------------------------------------------------------------------- + +// CreateCartRequest is the body for POST /carts. Optional — when empty, a fresh +// empty cart is created. When items are supplied the cart is seeded. +type CreateCartRequest struct { + Items []CartItemInput `json:"items,omitempty"` +} + +// UpdateCartRequest is the body for PUT /carts/{id}. It carries the full or +// partial cart state to replace. +type UpdateCartRequest struct { + Items []CartItemInput `json:"items,omitempty"` + Vouchers []VoucherInput `json:"vouchers,omitempty"` + UserId *string `json:"userId,omitempty"` + Country string `json:"country,omitempty"` +} + +// CartItemInput is the UCP wire format for an item being added/updated. +type CartItemInput struct { + Sku string `json:"sku"` + Quantity int `json:"quantity,omitempty"` + Name string `json:"name,omitempty"` + Price int64 `json:"price,omitempty"` // inc-vat in minor units (öre) + TaxRate float64 `json:"taxRate,omitempty"` // e.g. 25 or 12.5 + Image string `json:"image,omitempty"` + StoreId *string `json:"storeId,omitempty"` + Children []CartItemInput `json:"children,omitempty"` + Fields map[string]string `json:"customFields,omitempty"` +} + +// VoucherInput is the UCP wire format for a voucher code to apply. +type VoucherInput struct { + Code string `json:"code"` + Value int64 `json:"value,omitempty"` +} + +// CartResponse is the UCP cart response body. +type CartResponse struct { + Id string `json:"id"` + Items []CartItem `json:"items,omitempty"` + Totals Totals `json:"totals"` + Status string `json:"status"` +} + +// CartItem is the UCP wire format for a cart line item in responses. +type CartItem struct { + Sku string `json:"sku"` + Quantity int `json:"quantity"` + Name string `json:"name,omitempty"` + UnitPrice int64 `json:"unitPrice"` // inc-vat minor units + TotalPrice int64 `json:"totalPrice"` // inc-vat minor units + TaxRate int `json:"taxRate"` + Image string `json:"image,omitempty"` + ItemId uint32 `json:"itemId,omitempty"` + Fields map[string]string `json:"customFields,omitempty"` +} + +// Totals is the UCP wire format for price breakdown. +type Totals struct { + TotalIncVat int64 `json:"totalIncVat"` + TotalExVat int64 `json:"totalExVat"` + TotalVat int64 `json:"totalVat"` + Currency string `json:"currency"` + Discount int64 `json:"discount,omitempty"` +} + +// --------------------------------------------------------------------------- +// UCP Checkout types +// --------------------------------------------------------------------------- + +// CreateCheckoutRequest is the body for POST /checkout-sessions. +type CreateCheckoutRequest struct { + CartId string `json:"cartId"` + Items []CartItemInput `json:"items,omitempty"` + Country string `json:"country,omitempty"` + Currency string `json:"currency,omitempty"` +} + +// UpdateCheckoutRequest is the body for PUT /checkout-sessions/{id}. +type UpdateCheckoutRequest struct { + Delivery *DeliveryInput `json:"delivery,omitempty"` + Contact *ContactInput `json:"contact,omitempty"` + PickupPoint *PickupPointInput `json:"pickupPoint,omitempty"` +} + +// DeliveryInput carries a delivery option for a checkout session. +type DeliveryInput struct { + Provider string `json:"provider"` + ItemIds []uint32 `json:"itemIds,omitempty"` +} + +// ContactInput carries contact details for a checkout session. +type ContactInput struct { + Email *string `json:"email,omitempty"` + Phone *string `json:"phone,omitempty"` + Name *string `json:"name,omitempty"` + PostalCode *string `json:"postalCode,omitempty"` +} + +// PickupPointInput carries a pickup point for a delivery. +type PickupPointInput struct { + DeliveryId uint32 `json:"deliveryId"` + Id string `json:"id"` + Name *string `json:"name,omitempty"` + Address *string `json:"address,omitempty"` + City *string `json:"city,omitempty"` + Zip *string `json:"zip,omitempty"` +} + +// CompleteCheckoutRequest is the body for POST /checkout-sessions/{id}/complete. +type CompleteCheckoutRequest struct { + PaymentToken string `json:"paymentToken,omitempty"` + Provider string `json:"provider,omitempty"` + Currency string `json:"currency,omitempty"` + Country string `json:"country,omitempty"` +} + +// CheckoutResponse is the UCP checkout session response. +type CheckoutResponse struct { + Id string `json:"id"` + CartId string `json:"cartId"` + Status string `json:"status"` + Items []CartItem `json:"items,omitempty"` + Totals Totals `json:"totals"` + Deliveries []DeliveryResponse `json:"deliveries,omitempty"` + Contact *ContactResponse `json:"contact,omitempty"` + OrderId *string `json:"orderId,omitempty"` + Payments []PaymentResponse `json:"payments,omitempty"` +} + +// DeliveryResponse is the UCP wire format for a delivery option. +type DeliveryResponse struct { + Id uint32 `json:"id"` + Provider string `json:"provider"` + Price int64 `json:"price"` + Items []uint32 `json:"items"` + PickupPoint *PickupPointResp `json:"pickupPoint,omitempty"` +} + +// PickupPointResp is the UCP wire format for a pickup point. +type PickupPointResp struct { + Id string `json:"id"` + Name *string `json:"name,omitempty"` + Address *string `json:"address,omitempty"` + City *string `json:"city,omitempty"` + Zip *string `json:"zip,omitempty"` +} + +// ContactResponse is the UCP wire format for contact details. +type ContactResponse struct { + Email *string `json:"email,omitempty"` + Phone *string `json:"phone,omitempty"` + Name *string `json:"name,omitempty"` + PostalCode *string `json:"postalCode,omitempty"` +} + +// PaymentResponse is the UCP wire format for a payment record. +type PaymentResponse struct { + Id string `json:"id"` + Provider string `json:"provider"` + Amount int64 `json:"amount"` + Currency string `json:"currency"` + Status string `json:"status"` +} + +// --------------------------------------------------------------------------- +// UCP Order types +// --------------------------------------------------------------------------- + +// OrderResponse is the UCP order response body. +type OrderResponse struct { + OrderId string `json:"orderId"` + Reference string `json:"reference,omitempty"` + CartId string `json:"cartId,omitempty"` + Status string `json:"status"` + Currency string `json:"currency,omitempty"` + TotalAmount int64 `json:"totalAmount"` + TotalTax int64 `json:"totalTax"` + Lines []OrderLineResp `json:"lines,omitempty"` + Payments []OrderPaymentResp `json:"payments,omitempty"` + Fulfillments []OrderFulfillmentResp `json:"fulfillments,omitempty"` + Returns []OrderReturnResp `json:"returns,omitempty"` + Refunds []OrderRefundResp `json:"refunds,omitempty"` + CapturedAmount int64 `json:"capturedAmount"` + RefundedAmount int64 `json:"refundedAmount"` + CustomerEmail string `json:"customerEmail,omitempty"` + CustomerName string `json:"customerName,omitempty"` + PlacedAt string `json:"placedAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +// OrderLineResp represents a single order line item in responses. +type OrderLineResp struct { + Reference string `json:"reference"` + Sku string `json:"sku"` + Name string `json:"name,omitempty"` + Quantity int `json:"quantity"` + UnitPrice int64 `json:"unitPrice"` + TaxRate int `json:"taxRate"` + TotalAmount int64 `json:"totalAmount"` + TotalTax int64 `json:"totalTax"` + Fulfilled int `json:"fulfilled,omitempty"` +} + +// OrderPaymentResp represents a payment record on an order. +type OrderPaymentResp struct { + Provider string `json:"provider"` + Authorized int64 `json:"authorized"` + Captured int64 `json:"captured"` + Refunded int64 `json:"refunded"` + AuthRef string `json:"authRef,omitempty"` + CaptureRef string `json:"captureRef,omitempty"` +} + +// OrderFulfillmentResp represents a shipment record. +type OrderFulfillmentResp struct { + Id string `json:"id"` + Carrier string `json:"carrier,omitempty"` + TrackingNumber string `json:"trackingNumber,omitempty"` + TrackingURI string `json:"trackingUri,omitempty"` + Lines []OrderLineEntry `json:"lines,omitempty"` +} + +// OrderReturnResp represents a return request (RMA). +type OrderReturnResp struct { + Id string `json:"id"` + Reason string `json:"reason,omitempty"` + Lines []OrderLineEntry `json:"lines,omitempty"` +} + +// OrderRefundResp represents a refund record. +type OrderRefundResp struct { + Provider string `json:"provider"` + Amount int64 `json:"amount"` + Reference string `json:"reference,omitempty"` +} + +// OrderLineEntry is a lightweight reference+quantity pair used in fulfillment +// and return line lists. +type OrderLineEntry struct { + Reference string `json:"reference"` + Quantity int `json:"quantity"` +} + +// --------------------------------------------------------------------------- +// UCP Profile handler (/.well-known/ucp) +// --------------------------------------------------------------------------- + +// ProfileData is the UCP business profile served at /.well-known/ucp. +type ProfileData struct { + UCP Profile `json:"ucp"` +} + +// Profile is the core UCP profile object. +type Profile struct { + Version string `json:"version"` + Business BusinessInfo `json:"business,omitempty"` + Services map[string][]ServiceBinding `json:"services"` + Capabilities map[string][]CapabilityDecl `json:"capabilities"` + PaymentHandlers map[string][]PaymentHandlerDecl `json:"payment_handlers,omitempty"` + SupportedVersions []string `json:"supported_versions,omitempty"` +} + +// BusinessInfo describes the business behind a UCP profile. +type BusinessInfo struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Domain string `json:"domain"` +} + +// ServiceBinding describes a transport binding for a service namespace. +type ServiceBinding struct { + Version string `json:"version"` + Spec string `json:"spec"` + Transport string `json:"transport"` + Schema string `json:"schema"` + Endpoint string `json:"endpoint"` +} + +// CapabilityDecl describes a single capability version. +type CapabilityDecl struct { + Version string `json:"version"` + Spec string `json:"spec"` + Schema string `json:"schema"` + Extends string `json:"extends,omitempty"` + Description string `json:"description,omitempty"` + Operations []OpDef `json:"operations,omitempty"` +} + +// OpDef describes an operation method+path for a capability. +type OpDef struct { + Method string `json:"method"` + Path string `json:"path"` +} + +// PaymentHandlerDecl describes a payment handler specification. +type PaymentHandlerDecl struct { + ID string `json:"id"` + Version string `json:"version"` + Spec string `json:"spec"` + Schema string `json:"schema"` + Description string `json:"description,omitempty"` +} + +// --------------------------------------------------------------------------- +// HTTP helpers +// --------------------------------------------------------------------------- + +// writeJSON encodes v as JSON and writes it with the given status code. +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +// errorBody is a simple {"error": "…"} response. +type errorBody struct { + Error string `json:"error"` +} + +// writeError writes a JSON error response. +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, errorBody{Error: msg}) +} diff --git a/pkg/cart/mcp/jsonrpc.go b/pkg/cart/mcp/jsonrpc.go new file mode 100644 index 0000000..71f6263 --- /dev/null +++ b/pkg/cart/mcp/jsonrpc.go @@ -0,0 +1,44 @@ +package mcp + +import "encoding/json" + +// JSON-RPC 2.0 envelope types (the wire format MCP rides on). Notifications are +// requests with no id and must not receive a response. + +type rpcRequest struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +func (r *rpcRequest) isNotification() bool { return len(r.ID) == 0 } + +type rpcResponse struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Result any `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` + Data any `json:"data,omitempty"` +} + +const ( + codeParseError = -32700 + codeInvalidRequest = -32600 + codeMethodNotFound = -32601 + codeInvalidParams = -32602 + codeInternalError = -32603 +) + +func result(id json.RawMessage, v any) *rpcResponse { + return &rpcResponse{JSONRPC: "2.0", ID: id, Result: v} +} + +func errorResponse(id json.RawMessage, code int, msg string) *rpcResponse { + return &rpcResponse{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}} +} diff --git a/pkg/cart/mcp/mcp.go b/pkg/cart/mcp/mcp.go new file mode 100644 index 0000000..3027173 --- /dev/null +++ b/pkg/cart/mcp/mcp.go @@ -0,0 +1,159 @@ +// Package mcp is the MCP edge for the cart: a Model Context Protocol server +// exposing the cart grain as tools so an agent can inspect and mutate carts +// (list items, change quantities, remove items, clear carts, apply vouchers, +// manage users, etc.). +// +// Transport is streamable HTTP (JSON-RPC 2.0 over POST). It is mounted by +// cmd/cart under /mcp on the same HTTP server as the cart API. Tools map +// 1:1 to cart grain read/apply operations; no restart is needed because every +// tool call goes through the shared grain pool. +package mcp + +import ( + "context" + "encoding/json" + "io" + "net/http" + + "git.k6n.net/mats/go-cart-actor/pkg/actor" + "git.k6n.net/mats/go-cart-actor/pkg/cart" + "google.golang.org/protobuf/proto" +) + +const ( + serverName = "cart" + serverVersion = "0.1.0" + protocolVersion = "2025-06-18" +) + +// Applier is the minimal grain-pool interface the cart MCP needs: read a +// cart's current state (Get) and apply a mutation (Apply). The +// SimpleGrainPool[cart.CartGrain] in cmd/cart satisfies it directly. +type Applier interface { + Get(ctx context.Context, id uint64) (*cart.CartGrain, error) + Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[cart.CartGrain], error) +} + +// Server is the MCP edge over the cart grain pool. +type Server struct { + applier Applier + tools []tool +} + +// New builds an MCP server exposing the cart grain as tools. +func New(applier Applier) *Server { + s := &Server{applier: applier} + s.tools = s.buildTools() + return s +} + +// Handler returns the streamable-HTTP handler to mount (e.g. at /mcp). +func (s *Server) Handler() http.Handler { + return http.HandlerFunc(s.serveHTTP) +} + +func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20)) + if err != nil { + writeRPC(w, errorResponse(nil, codeParseError, "read error")) + return + } + if isBatch(body) { + var reqs []rpcRequest + if err := json.Unmarshal(body, &reqs); err != nil { + writeRPC(w, errorResponse(nil, codeParseError, "invalid JSON")) + return + } + var out []*rpcResponse + for i := range reqs { + if resp := s.dispatch(&reqs[i]); resp != nil { + out = append(out, resp) + } + } + if len(out) == 0 { + w.WriteHeader(http.StatusAccepted) + return + } + writeRPC(w, out) + return + } + var req rpcRequest + if err := json.Unmarshal(body, &req); err != nil { + writeRPC(w, errorResponse(nil, codeParseError, "invalid JSON")) + return + } + resp := s.dispatch(&req) + if resp == nil { + w.WriteHeader(http.StatusAccepted) + return + } + writeRPC(w, resp) +} + +func (s *Server) dispatch(req *rpcRequest) *rpcResponse { + if req.JSONRPC != "2.0" { + if req.isNotification() { + return nil + } + return errorResponse(req.ID, codeInvalidRequest, "jsonrpc must be \"2.0\"") + } + switch req.Method { + case "initialize": + return result(req.ID, s.initialize(req.Params)) + case "ping": + return result(req.ID, struct{}{}) + case "tools/list": + return result(req.ID, map[string]any{"tools": s.tools}) + case "tools/call": + return s.callTool(req) + case "notifications/initialized", "notifications/cancelled": + return nil + default: + if req.isNotification() { + return nil + } + return errorResponse(req.ID, codeMethodNotFound, "unknown method: "+req.Method) + } +} + +func (s *Server) initialize(params json.RawMessage) map[string]any { + pv := protocolVersion + if len(params) > 0 { + var p struct { + ProtocolVersion string `json:"protocolVersion"` + } + if json.Unmarshal(params, &p) == nil && p.ProtocolVersion != "" { + pv = p.ProtocolVersion + } + } + return map[string]any{ + "protocolVersion": pv, + "capabilities": map[string]any{"tools": map[string]any{}}, + "serverInfo": map[string]any{"name": serverName, "version": serverVersion}, + } +} + +func isBatch(body []byte) bool { + for _, b := range body { + switch b { + case ' ', '\t', '\r', '\n': + continue + case '[': + return true + default: + return false + } + } + return false +} + +func writeRPC(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/pkg/cart/mcp/mcp_test.go b/pkg/cart/mcp/mcp_test.go new file mode 100644 index 0000000..7a2f46a --- /dev/null +++ b/pkg/cart/mcp/mcp_test.go @@ -0,0 +1,421 @@ +package mcp + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "git.k6n.net/mats/go-cart-actor/pkg/actor" + "git.k6n.net/mats/go-cart-actor/pkg/cart" + "google.golang.org/protobuf/proto" +) + +// testApplier implements Applier with an in-memory map of carts, using the real +// cart mutation registry so write tools exercise the actual mutation handlers. +type testApplier struct { + grains map[uint64]*cart.CartGrain + reg actor.MutationRegistry +} + +func newTestApplier() *testApplier { + reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil)) + return &testApplier{ + grains: make(map[uint64]*cart.CartGrain), + reg: reg, + } +} + +func (a *testApplier) addCart(id uint64, items int) *cart.CartGrain { + g := cart.NewCartGrain(id, time.Now()) + g.Currency = "SEK" + g.Language = "sv-se" + for i := 1; i <= items; i++ { + g.Items = append(g.Items, &cart.CartItem{ + Id: uint32(i), + ItemId: uint32(1000 + i), + Sku: fmt.Sprintf("SKU-%03d", i), + Quantity: uint16(i), + Price: *cart.NewPriceFromIncVat(int64(i*10000), 25), + Tax: 2500, + Meta: &cart.ItemMeta{Name: fmt.Sprintf("Item %d", i)}, + }) + } + g.UpdateTotals() + a.grains[id] = g + return g +} + +func (a *testApplier) Get(_ context.Context, id uint64) (*cart.CartGrain, error) { + g, ok := a.grains[id] + if !ok { + return nil, fmt.Errorf("cart %d not found", id) + } + return g, nil +} + +func (a *testApplier) Apply(ctx context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[cart.CartGrain], error) { + g, ok := a.grains[id] + if !ok { + return nil, fmt.Errorf("cart %d not found", id) + } + results, err := a.reg.Apply(ctx, g, msgs...) + if err != nil { + return nil, err + } + // Re-read state after mutations. + state, err := g.GetCurrentState() + if err != nil { + return nil, err + } + return &actor.MutationResult[cart.CartGrain]{ + Result: *state, //nolint:govet // test helper snapshot, same pattern as SimpleGrainPool + Mutations: results, + }, nil + +} + +// call issues a tools/call against the handler and returns the decoded text +// payload (the JSON the tool returned), failing on a tool/transport error. +func call(t *testing.T, h http.Handler, name string, args map[string]any) map[string]any { + t.Helper() + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": map[string]any{"name": name, "arguments": args}, + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body)) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("%s: status %d", name, rec.Code) + } + var resp struct { + Result struct { + IsError bool `json:"isError"` + Content []struct { + Text string `json:"text"` + } `json:"content"` + } `json:"result"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("%s: decode response: %v (%s)", name, err, rec.Body.String()) + } + if resp.Error != nil { + t.Fatalf("%s: rpc error: %s", name, resp.Error.Message) + } + if resp.Result.IsError { + t.Fatalf("%s: tool error: %s", name, resp.Result.Content[0].Text) + } + var out map[string]any + if len(resp.Result.Content) > 0 { + _ = json.Unmarshal([]byte(resp.Result.Content[0].Text), &out) + } + return out +} + +func TestCartMCPRoundTrip(t *testing.T) { + a := newTestApplier() + a.addCart(42, 3) // cart 42 with 3 items + h := New(a).Handler() + + // get_cart + got := call(t, h, "get_cart", map[string]any{"cartId": "g"}) + if got == nil { + t.Fatal("get_cart returned nil") + } + if id, _ := got["id"].(string); id != "g" { + t.Errorf("get_cart id = %v, want g", got["id"]) + } + if curr, _ := got["currency"].(string); curr != "SEK" { + t.Errorf("get_cart currency = %q, want SEK", curr) + } + + // get_cart_items + items := call(t, h, "get_cart_items", map[string]any{"cartId": "g"}) + if c, _ := items["count"].(float64); c != 3 { + t.Errorf("get_cart_items count = %v, want 3", items["count"]) + } + + // get_cart_totals + totals := call(t, h, "get_cart_totals", map[string]any{"cartId": "g"}) + if _, ok := totals["totalPrice"]; !ok { + t.Errorf("get_cart_totals missing totalPrice: %v", totals) + } + if _, ok := totals["totalDiscount"]; !ok { + t.Errorf("get_cart_totals missing totalDiscount: %v", totals) + } + + // get_cart_vouchers (empty cart) + vouchers := call(t, h, "get_cart_vouchers", map[string]any{"cartId": "g"}) + if c, _ := vouchers["count"].(float64); c != 0 { + t.Errorf("get_cart_vouchers count = %v, want 0", vouchers["count"]) + } + + // update_item_quantity: change item id=1 from qty 1 to qty 5 + updated := call(t, h, "update_item_quantity", map[string]any{ + "cartId": "g", "itemId": 1, "quantity": 5, + }) + if updated == nil { + t.Fatal("update_item_quantity returned nil") + } + // Verify via get_cart_items + items2 := call(t, h, "get_cart_items", map[string]any{"cartId": "g"}) + itemsRaw, _ := items2["items"].([]any) + if len(itemsRaw) != 3 { + t.Fatalf("after qty change: want 3 items, got %d", len(itemsRaw)) + } + firstItem := itemsRaw[0].(map[string]any) + if q, _ := firstItem["qty"].(float64); q != 5 { + t.Errorf("item 1 qty = %v, want 5", q) + } + + // remove_cart_item: remove item id=2 + removed := call(t, h, "remove_cart_item", map[string]any{ + "cartId": "g", "itemId": 2, + }) + if removed == nil { + t.Fatal("remove_cart_item returned nil") + } + items3 := call(t, h, "get_cart_items", map[string]any{"cartId": "g"}) + itemsRaw3, _ := items3["items"].([]any) + if len(itemsRaw3) != 2 { + t.Fatalf("after remove: want 2 items, got %d", len(itemsRaw3)) + } + + // apply_voucher + withVoucher := call(t, h, "apply_voucher", map[string]any{ + "cartId": "g", "code": "SAVE10", "value": 10000, + }) + if withVoucher == nil { + t.Fatal("apply_voucher returned nil") + } + vouchers2 := call(t, h, "get_cart_vouchers", map[string]any{"cartId": "g"}) + if c, _ := vouchers2["count"].(float64); c != 1 { + t.Errorf("after voucher apply: count = %v, want 1", vouchers2["count"]) + } + + // remove_voucher: find the voucher id + voucherList, _ := vouchers2["vouchers"].([]any) + if len(voucherList) > 0 { + v := voucherList[0].(map[string]any) + vID := v["id"].(float64) + call(t, h, "remove_voucher", map[string]any{ + "cartId": "g", "voucherId": int(vID), + }) + vouchers3 := call(t, h, "get_cart_vouchers", map[string]any{"cartId": "g"}) + if c, _ := vouchers3["count"].(float64); c != 0 { + t.Errorf("after remove voucher: count = %v, want 0", vouchers3["count"]) + } + } + + // set_cart_user + withUser := call(t, h, "set_cart_user", map[string]any{ + "cartId": "g", "userId": "user-abc-123", + }) + if withUser == nil { + t.Fatal("set_cart_user returned nil") + } + + // clear_cart + cleared := call(t, h, "clear_cart", map[string]any{"cartId": "g"}) + if cleared == nil { + t.Fatal("clear_cart returned nil") + } + items4 := call(t, h, "get_cart_items", map[string]any{"cartId": "g"}) + if c, _ := items4["count"].(float64); c != 0 { + t.Errorf("after clear: count = %v, want 0", items4["count"]) + } +} + +func TestCartMCPErrors(t *testing.T) { + a := newTestApplier() + a.addCart(1, 1) + h := New(a).Handler() + + // Invalid cart id -> isError result. + result := callWithRaw(t, h, "get_cart", map[string]any{"cartId": "!!!"}) + if !result.IsError { + t.Fatal("expected isError for invalid cart id") + } + + // Missing required cartId -> still hits decode (cartId absent is empty string) + result2 := callWithRaw(t, h, "get_cart", map[string]any{}) + if !result2.IsError { + t.Fatal("expected isError for missing cartId") + } + + // Non-existent cart -> isError result. + result3 := callWithRaw(t, h, "get_cart", map[string]any{"cartId": "zzzzzz"}) + if !result3.IsError { + t.Fatal("expected isError for non-existent cart") + } + + // Remove non-existent item -> isError + result4 := callWithRaw(t, h, "remove_cart_item", map[string]any{ + "cartId": "1", "itemId": 999, + }) + if !result4.IsError { + t.Fatal("expected isError for non-existent item") + } + + // Unknown tool -> protocol error. + var resp struct { + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": map[string]any{"name": "nope", "arguments": map[string]any{}}, + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body)) + h.ServeHTTP(rec, req) + json.Unmarshal(rec.Body.Bytes(), &resp) + if resp.Error == nil { + t.Fatal("expected protocol error for unknown tool") + } +} + +// callWithRaw returns the raw result (including isError) without failing. +type rawResult struct { + IsError bool + Content []struct { + Text string `json:"text"` + } +} + +func callWithRaw(t *testing.T, h http.Handler, name string, args map[string]any) rawResult { + t.Helper() + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": map[string]any{"name": name, "arguments": args}, + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body)) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("%s: status %d", name, rec.Code) + } + var resp struct { + Result rawResult `json:"result"` + Error *struct{ Message string } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("%s: decode: %v", name, err) + } + if resp.Error != nil { + t.Fatalf("%s: rpc error: %s", name, resp.Error.Message) + } + return resp.Result +} + +func TestInitializeAndToolsList(t *testing.T) { + a := newTestApplier() + h := New(a).Handler() + + // Initialize. + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": map[string]any{"protocolVersion": "2025-06-18"}, + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body)) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("initialize: status %d", rec.Code) + } + var initResp struct { + Result struct { + ProtocolVersion string `json:"protocolVersion"` + ServerInfo struct { + Name string `json:"name"` + } `json:"serverInfo"` + } `json:"result"` + } + json.Unmarshal(rec.Body.Bytes(), &initResp) + if initResp.Result.ProtocolVersion != "2025-06-18" || initResp.Result.ServerInfo.Name != "cart" { + t.Fatalf("unexpected initialize: %+v", initResp) + } + + // tools/list. + body2, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": "tools/list", + }) + rec2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body2)) + h.ServeHTTP(rec2, req2) + var listResp struct { + Result struct { + Tools []struct { + Name string `json:"name"` + InputSchema json.RawMessage `json:"inputSchema"` + } `json:"tools"` + } `json:"result"` + } + json.Unmarshal(rec2.Body.Bytes(), &listResp) + want := map[string]bool{ + "get_cart": false, "update_item_quantity": false, + "remove_cart_item": false, "clear_cart": false, + } + for _, tl := range listResp.Result.Tools { + if _, ok := want[tl.Name]; ok { + want[tl.Name] = true + } + if len(tl.InputSchema) == 0 { + t.Fatalf("tool %s has empty inputSchema", tl.Name) + } + } + for name, found := range want { + if !found { + t.Fatalf("expected tool %q in tools/list", name) + } + } +} + +func TestCartMCPCartIdRoundTrip(t *testing.T) { + // Verify that we can round-trip a base62 cart id through tools. + a := newTestApplier() + // Use a larger cart id to test base62 encoding. + g := cart.NewCartGrain(12345, time.Now()) + g.Currency = "EUR" + a.grains[12345] = g + h := New(a).Handler() + + got := call(t, h, "get_cart", map[string]any{"cartId": g.Id.String()}) + if curr, _ := got["currency"].(string); curr != "EUR" { + t.Errorf("currency = %q, want EUR", curr) + } +} + +func TestGetCartPromotions(t *testing.T) { + a := newTestApplier() + g := a.addCart(99, 2) + // Add a synthetic applied promotion. + g.AppliedPromotions = []cart.AppliedPromotion{ + {PromotionId: "volymrabatt", Name: "Volymrabatt", Type: "tiered_discount", Discount: cart.NewPriceFromIncVat(10000, 25)}, + } + h := New(a).Handler() + + result := call(t, h, "get_cart_promotions", map[string]any{"cartId": g.Id.String()}) + if result == nil { + t.Fatal("get_cart_promotions returned nil") + } + promos, _ := result["appliedPromotions"].([]any) + if len(promos) != 1 { + t.Fatalf("want 1 promotion, got %d", len(promos)) + } + p := promos[0].(map[string]any) + if name, _ := p["name"].(string); name != "Volymrabatt" { + t.Errorf("promotion name = %q, want Volymrabatt", name) + } +} diff --git a/pkg/cart/mcp/tools.go b/pkg/cart/mcp/tools.go new file mode 100644 index 0000000..879873c --- /dev/null +++ b/pkg/cart/mcp/tools.go @@ -0,0 +1,439 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + + "git.k6n.net/mats/go-cart-actor/pkg/cart" + messages "git.k6n.net/mats/go-cart-actor/proto/cart" +) + +// tool is one MCP tool: a name, a description, a JSON-Schema for its arguments, +// and the handler that runs it. +type tool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema json.RawMessage `json:"inputSchema"` + invoke func(args json.RawMessage) (any, error) `json:"-"` +} + +func (s *Server) callTool(req *rpcRequest) *rpcResponse { + var p struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + } + if err := json.Unmarshal(req.Params, &p); err != nil { + return errorResponse(req.ID, codeInvalidParams, "invalid params") + } + var t *tool + for i := range s.tools { + if s.tools[i].Name == p.Name { + t = &s.tools[i] + break + } + } + if t == nil { + return errorResponse(req.ID, codeInvalidParams, "unknown tool: "+p.Name) + } + out, err := t.invoke(p.Arguments) + if err != nil { + return result(req.ID, toolError(err)) + } + return result(req.ID, toolText(out)) +} + +func (s *Server) buildTools() []tool { + return []tool{ + { + Name: "get_cart", + Description: "Get the full state of a cart by its base62 cart id: items, totals, vouchers, promotions, user info, currency, language, checkout status, subscription details, and all applied/pending promotions with progress nudges.", + InputSchema: object(props{ + "cartId": str("the base62 cart id"), + }, []string{"cartId"}), + invoke: func(args json.RawMessage) (any, error) { + var a struct { + CartID string `json:"cartId"` + } + if err := decode(args, &a); err != nil { + return nil, err + } + id, ok := cart.ParseCartId(a.CartID) + if !ok { + return nil, fmt.Errorf("invalid cart id %q", a.CartID) + } + g, err := s.applier.Get(context.Background(), uint64(id)) + if err != nil { + return nil, fmt.Errorf("get cart: %w", err) + } + return g, nil + }, + }, + { + Name: "get_cart_items", + Description: "List all items in a cart with their SKU, name, quantity, unit price, total price, stock, tax rate, markings, custom fields, and optional child/parent relationships.", + InputSchema: object(props{ + "cartId": str("the base62 cart id"), + }, []string{"cartId"}), + invoke: func(args json.RawMessage) (any, error) { + var a struct { + CartID string `json:"cartId"` + } + if err := decode(args, &a); err != nil { + return nil, err + } + id, ok := cart.ParseCartId(a.CartID) + if !ok { + return nil, fmt.Errorf("invalid cart id %q", a.CartID) + } + g, err := s.applier.Get(context.Background(), uint64(id)) + if err != nil { + return nil, fmt.Errorf("get cart: %w", err) + } + return map[string]any{"items": g.Items, "count": len(g.Items)}, nil + }, + }, + { + Name: "get_cart_totals", + Description: "Get price breakdown for a cart: total incVat, total exVat, VAT breakdown by rate, total discount (vouchers + promotions), and per-item totals.", + InputSchema: object(props{ + "cartId": str("the base62 cart id"), + }, []string{"cartId"}), + invoke: func(args json.RawMessage) (any, error) { + var a struct { + CartID string `json:"cartId"` + } + if err := decode(args, &a); err != nil { + return nil, err + } + id, ok := cart.ParseCartId(a.CartID) + if !ok { + return nil, fmt.Errorf("invalid cart id %q", a.CartID) + } + g, err := s.applier.Get(context.Background(), uint64(id)) + if err != nil { + return nil, fmt.Errorf("get cart: %w", err) + } + return map[string]any{ + "totalPrice": g.TotalPrice, + "totalDiscount": g.TotalDiscount, + "currency": g.Currency, + "language": g.Language, + "vouchers": g.Vouchers, + }, nil + }, + }, + { + Name: "get_cart_promotions", + Description: "Get all applied and pending promotions on a cart, including discount amounts and progress nudges (e.g. 'spend 1200 SEK more for free shipping').", + InputSchema: object(props{ + "cartId": str("the base62 cart id"), + }, []string{"cartId"}), + invoke: func(args json.RawMessage) (any, error) { + var a struct { + CartID string `json:"cartId"` + } + if err := decode(args, &a); err != nil { + return nil, err + } + id, ok := cart.ParseCartId(a.CartID) + if !ok { + return nil, fmt.Errorf("invalid cart id %q", a.CartID) + } + g, err := s.applier.Get(context.Background(), uint64(id)) + if err != nil { + return nil, fmt.Errorf("get cart: %w", err) + } + return map[string]any{ + "appliedPromotions": g.AppliedPromotions, + "totalDiscount": g.TotalDiscount, + }, nil + }, + }, + { + Name: "get_cart_vouchers", + Description: "List all vouchers applied to a cart with their codes, values, rules, and whether they are currently active.", + InputSchema: object(props{ + "cartId": str("the base62 cart id"), + }, []string{"cartId"}), + invoke: func(args json.RawMessage) (any, error) { + var a struct { + CartID string `json:"cartId"` + } + if err := decode(args, &a); err != nil { + return nil, err + } + id, ok := cart.ParseCartId(a.CartID) + if !ok { + return nil, fmt.Errorf("invalid cart id %q", a.CartID) + } + g, err := s.applier.Get(context.Background(), uint64(id)) + if err != nil { + return nil, fmt.Errorf("get cart: %w", err) + } + return map[string]any{"vouchers": g.Vouchers, "count": len(g.Vouchers)}, nil + }, + }, + { + Name: "update_item_quantity", + Description: "Change the quantity of a line item in a cart. Use quantity=0 to remove the item. Returns the updated cart state.", + InputSchema: object(props{ + "cartId": str("the base62 cart id"), + "itemId": integer("the line item id (numeric, e.g. 1, 2, 3)"), + "quantity": integer("the new quantity (0 removes the item)"), + }, []string{"cartId", "itemId", "quantity"}), + invoke: func(args json.RawMessage) (any, error) { + var a struct { + CartID string `json:"cartId"` + ItemID int `json:"itemId"` + Quantity int32 `json:"quantity"` + } + if err := decode(args, &a); err != nil { + return nil, err + } + id, ok := cart.ParseCartId(a.CartID) + if !ok { + return nil, fmt.Errorf("invalid cart id %q", a.CartID) + } + res, err := s.applier.Apply(context.Background(), uint64(id), &messages.ChangeQuantity{ + Id: uint32(a.ItemID), + Quantity: a.Quantity, + }) + if err != nil { + return nil, fmt.Errorf("change quantity: %w", err) + } + // Check for per-mutation errors. + for _, m := range res.Mutations { + if m.Error != nil { + return nil, m.Error + } + } + return map[string]any{"cartId": a.CartID, "cart": &res.Result}, nil + }, + }, + { + Name: "remove_cart_item", + Description: "Remove a line item (and its children) from a cart by item id. Returns the updated cart state.", + InputSchema: object(props{ + "cartId": str("the base62 cart id"), + "itemId": integer("the line item id to remove (numeric, e.g. 1, 2, 3)"), + }, []string{"cartId", "itemId"}), + invoke: func(args json.RawMessage) (any, error) { + var a struct { + CartID string `json:"cartId"` + ItemID int `json:"itemId"` + } + if err := decode(args, &a); err != nil { + return nil, err + } + id, ok := cart.ParseCartId(a.CartID) + if !ok { + return nil, fmt.Errorf("invalid cart id %q", a.CartID) + } + res, err := s.applier.Apply(context.Background(), uint64(id), &messages.RemoveItem{Id: uint32(a.ItemID)}) + if err != nil { + return nil, fmt.Errorf("remove item: %w", err) + } + for _, m := range res.Mutations { + if m.Error != nil { + return nil, m.Error + } + } + return map[string]any{"cartId": a.CartID, "cart": &res.Result}, nil + }, + }, + { + Name: "clear_cart", + Description: "Remove all items and vouchers from a cart, resetting it to an empty state. The cart id, user id, and currency are preserved. Returns the updated (empty) cart.", + InputSchema: object(props{ + "cartId": str("the base62 cart id"), + }, []string{"cartId"}), + invoke: func(args json.RawMessage) (any, error) { + var a struct { + CartID string `json:"cartId"` + } + if err := decode(args, &a); err != nil { + return nil, err + } + id, ok := cart.ParseCartId(a.CartID) + if !ok { + return nil, fmt.Errorf("invalid cart id %q", a.CartID) + } + res, err := s.applier.Apply(context.Background(), uint64(id), &messages.ClearCartRequest{}) + if err != nil { + return nil, fmt.Errorf("clear cart: %w", err) + } + for _, m := range res.Mutations { + if m.Error != nil { + return nil, m.Error + } + } + return map[string]any{"cartId": a.CartID, "cart": &res.Result}, nil + }, + }, + { + Name: "apply_voucher", + Description: "Apply a voucher code to a cart. The voucher value is in öre (e.g. 10000 = 100 kr). Returns the updated cart with the voucher applied.", + InputSchema: object(props{ + "cartId": str("the base62 cart id"), + "code": str("the voucher code"), + "value": integer("the voucher value in öre (e.g. 10000 = 100 kr)"), + "description": str("optional description of the voucher"), + "rules": stringArray("optional list of rule expressions for when the voucher applies"), + }, []string{"cartId", "code", "value"}), + invoke: func(args json.RawMessage) (any, error) { + var a struct { + CartID string `json:"cartId"` + Code string `json:"code"` + Value int64 `json:"value"` + Description string `json:"description"` + Rules []string `json:"rules"` + } + if err := decode(args, &a); err != nil { + return nil, err + } + id, ok := cart.ParseCartId(a.CartID) + if !ok { + return nil, fmt.Errorf("invalid cart id %q", a.CartID) + } + res, err := s.applier.Apply(context.Background(), uint64(id), &messages.AddVoucher{ + Code: a.Code, + Value: a.Value, + Description: a.Description, + VoucherRules: a.Rules, + }) + if err != nil { + return nil, fmt.Errorf("apply voucher: %w", err) + } + for _, m := range res.Mutations { + if m.Error != nil { + return nil, m.Error + } + } + return map[string]any{"cartId": a.CartID, "cart": &res.Result}, nil + }, + }, + { + Name: "remove_voucher", + Description: "Remove a voucher from a cart by its voucher id. Returns the updated cart.", + InputSchema: object(props{ + "cartId": str("the base62 cart id"), + "voucherId": integer("the voucher id to remove (numeric)"), + }, []string{"cartId", "voucherId"}), + invoke: func(args json.RawMessage) (any, error) { + var a struct { + CartID string `json:"cartId"` + VoucherID int `json:"voucherId"` + } + if err := decode(args, &a); err != nil { + return nil, err + } + id, ok := cart.ParseCartId(a.CartID) + if !ok { + return nil, fmt.Errorf("invalid cart id %q", a.CartID) + } + res, err := s.applier.Apply(context.Background(), uint64(id), &messages.RemoveVoucher{Id: uint32(a.VoucherID)}) + if err != nil { + return nil, fmt.Errorf("remove voucher: %w", err) + } + for _, m := range res.Mutations { + if m.Error != nil { + return nil, m.Error + } + } + return map[string]any{"cartId": a.CartID, "cart": &res.Result}, nil + }, + }, + { + Name: "set_cart_user", + Description: "Set or update the user ID on a cart, linking it to a customer account. Returns the updated cart.", + InputSchema: object(props{ + "cartId": str("the base62 cart id"), + "userId": str("the user/customer id"), + }, []string{"cartId", "userId"}), + invoke: func(args json.RawMessage) (any, error) { + var a struct { + CartID string `json:"cartId"` + UserID string `json:"userId"` + } + if err := decode(args, &a); err != nil { + return nil, err + } + id, ok := cart.ParseCartId(a.CartID) + if !ok { + return nil, fmt.Errorf("invalid cart id %q", a.CartID) + } + res, err := s.applier.Apply(context.Background(), uint64(id), &messages.SetUserId{UserId: a.UserID}) + if err != nil { + return nil, fmt.Errorf("set user: %w", err) + } + for _, m := range res.Mutations { + if m.Error != nil { + return nil, m.Error + } + } + return map[string]any{"cartId": a.CartID, "cart": &res.Result}, nil + }, + }, + } +} + +// ---- result + schema helpers ----------------------------------------------- + +func toolText(v any) map[string]any { + b, err := json.Marshal(v) + if err != nil { + return toolError(err) + } + return map[string]any{ + "content": []map[string]any{{"type": "text", "text": string(b)}}, + } +} + +func toolError(err error) map[string]any { + return map[string]any{ + "isError": true, + "content": []map[string]any{{"type": "text", "text": err.Error()}}, + } +} + +// decode unmarshals tool arguments, tolerating empty/absent arguments. +func decode(args json.RawMessage, v any) error { + if len(args) == 0 || string(args) == "null" { + return nil + } + if err := json.Unmarshal(args, v); err != nil { + return fmt.Errorf("invalid arguments: %w", err) + } + return nil +} + +type props map[string]json.RawMessage + +func object(p props, required []string) json.RawMessage { + m := map[string]any{ + "type": "object", + "properties": p, + } + if len(required) > 0 { + m["required"] = required + } + b, _ := json.Marshal(m) + return b +} + +func str(desc string) json.RawMessage { return scalar("string", desc) } +func integer(d string) json.RawMessage { return scalar("integer", d) } +func obj(desc string) json.RawMessage { return scalar("object", desc) } + +func scalar(typ, desc string) json.RawMessage { + b, _ := json.Marshal(map[string]any{"type": typ, "description": desc}) + return b +} + +func stringArray(desc string) json.RawMessage { + b, _ := json.Marshal(map[string]any{ + "type": "array", "description": desc, "items": map[string]string{"type": "string"}, + }) + return b +} diff --git a/pkg/order/mcp/jsonrpc.go b/pkg/order/mcp/jsonrpc.go new file mode 100644 index 0000000..71f6263 --- /dev/null +++ b/pkg/order/mcp/jsonrpc.go @@ -0,0 +1,44 @@ +package mcp + +import "encoding/json" + +// JSON-RPC 2.0 envelope types (the wire format MCP rides on). Notifications are +// requests with no id and must not receive a response. + +type rpcRequest struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +func (r *rpcRequest) isNotification() bool { return len(r.ID) == 0 } + +type rpcResponse struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Result any `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` + Data any `json:"data,omitempty"` +} + +const ( + codeParseError = -32700 + codeInvalidRequest = -32600 + codeMethodNotFound = -32601 + codeInvalidParams = -32602 + codeInternalError = -32603 +) + +func result(id json.RawMessage, v any) *rpcResponse { + return &rpcResponse{JSONRPC: "2.0", ID: id, Result: v} +} + +func errorResponse(id json.RawMessage, code int, msg string) *rpcResponse { + return &rpcResponse{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}} +} diff --git a/pkg/order/mcp/mcp.go b/pkg/order/mcp/mcp.go new file mode 100644 index 0000000..b845c71 --- /dev/null +++ b/pkg/order/mcp/mcp.go @@ -0,0 +1,158 @@ +// Package mcp is the MCP edge for the order: a Model Context Protocol server +// exposing the order grain as tools so an agent can inspect order state and +// drive the order lifecycle (cancel, fulfill, complete, return, refund, etc.). +// +// Transport is streamable HTTP (JSON-RPC 2.0 over POST). It is mounted by +// cmd/order under /mcp on the same HTTP server as the order API. Tools map +// 1:1 to order grain read/apply operations; no restart is needed because every +// tool call goes through the shared grain pool. +package mcp + +import ( + "context" + "encoding/json" + "io" + "net/http" + + "git.k6n.net/mats/go-cart-actor/pkg/actor" + "git.k6n.net/mats/go-cart-actor/pkg/order" + "google.golang.org/protobuf/proto" +) + +const ( + serverName = "orders" + serverVersion = "0.1.0" + protocolVersion = "2025-06-18" +) + +// OrderApplier is the minimal grain-pool interface the order MCP needs: read an +// order's current state (Get) and apply a mutation (Apply). The +// SimpleGrainPool[order.OrderGrain] in cmd/order satisfies it directly. +type OrderApplier interface { + Get(ctx context.Context, id uint64) (*order.OrderGrain, error) + Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[order.OrderGrain], error) +} + +// Server is the MCP edge over the order grain pool. +type Server struct { + applier OrderApplier + tools []tool +} + +// New builds an MCP server exposing the order grain as tools. +func New(applier OrderApplier) *Server { + s := &Server{applier: applier} + s.tools = s.buildTools() + return s +} + +// Handler returns the streamable-HTTP handler to mount (e.g. at /mcp). +func (s *Server) Handler() http.Handler { + return http.HandlerFunc(s.serveHTTP) +} + +func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20)) + if err != nil { + writeRPC(w, errorResponse(nil, codeParseError, "read error")) + return + } + if isBatch(body) { + var reqs []rpcRequest + if err := json.Unmarshal(body, &reqs); err != nil { + writeRPC(w, errorResponse(nil, codeParseError, "invalid JSON")) + return + } + var out []*rpcResponse + for i := range reqs { + if resp := s.dispatch(&reqs[i]); resp != nil { + out = append(out, resp) + } + } + if len(out) == 0 { + w.WriteHeader(http.StatusAccepted) + return + } + writeRPC(w, out) + return + } + var req rpcRequest + if err := json.Unmarshal(body, &req); err != nil { + writeRPC(w, errorResponse(nil, codeParseError, "invalid JSON")) + return + } + resp := s.dispatch(&req) + if resp == nil { + w.WriteHeader(http.StatusAccepted) + return + } + writeRPC(w, resp) +} + +func (s *Server) dispatch(req *rpcRequest) *rpcResponse { + if req.JSONRPC != "2.0" { + if req.isNotification() { + return nil + } + return errorResponse(req.ID, codeInvalidRequest, "jsonrpc must be \"2.0\"") + } + switch req.Method { + case "initialize": + return result(req.ID, s.initialize(req.Params)) + case "ping": + return result(req.ID, struct{}{}) + case "tools/list": + return result(req.ID, map[string]any{"tools": s.tools}) + case "tools/call": + return s.callTool(req) + case "notifications/initialized", "notifications/cancelled": + return nil + default: + if req.isNotification() { + return nil + } + return errorResponse(req.ID, codeMethodNotFound, "unknown method: "+req.Method) + } +} + +func (s *Server) initialize(params json.RawMessage) map[string]any { + pv := protocolVersion + if len(params) > 0 { + var p struct { + ProtocolVersion string `json:"protocolVersion"` + } + if json.Unmarshal(params, &p) == nil && p.ProtocolVersion != "" { + pv = p.ProtocolVersion + } + } + return map[string]any{ + "protocolVersion": pv, + "capabilities": map[string]any{"tools": map[string]any{}}, + "serverInfo": map[string]any{"name": serverName, "version": serverVersion}, + } +} + +func isBatch(body []byte) bool { + for _, b := range body { + switch b { + case ' ', '\t', '\r', '\n': + continue + case '[': + return true + default: + return false + } + } + return false +} + +func writeRPC(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/pkg/order/mcp/mcp_test.go b/pkg/order/mcp/mcp_test.go new file mode 100644 index 0000000..ab74e27 --- /dev/null +++ b/pkg/order/mcp/mcp_test.go @@ -0,0 +1,457 @@ +package mcp + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "git.k6n.net/mats/go-cart-actor/pkg/actor" + "git.k6n.net/mats/go-cart-actor/pkg/order" + messages "git.k6n.net/mats/go-cart-actor/proto/order" + "google.golang.org/protobuf/proto" +) + +// testApplier implements OrderApplier with an in-memory map of orders, using +// the real order mutation registry so write tools exercise the actual handlers. +type testApplier struct { + grains map[uint64]*order.OrderGrain + reg actor.MutationRegistry +} + +func newTestApplier() *testApplier { + reg := actor.NewMutationRegistry() + order.RegisterMutations(reg) + return &testApplier{ + grains: make(map[uint64]*order.OrderGrain), + reg: reg, + } +} + +// addPlacedOrder creates a fully placed order with n line items, returning its +// base62 id string. +func (a *testApplier) addPlacedOrder(id uint64, items int) string { + g := order.NewOrderGrain(id, time.Now()) + lines := make([]*messages.OrderLine, items) + for i := 0; i < items; i++ { + lines[i] = &messages.OrderLine{ + Reference: fmt.Sprintf("line-%d", i+1), + Sku: fmt.Sprintf("SKU-%03d", i+1), + Name: fmt.Sprintf("Item %d", i+1), + Quantity: int32(i + 1), + UnitPrice: int64((i + 1) * 10000), + TaxRate: 2500, + } + } + // Place the order through the real handler. + po := &messages.PlaceOrder{ + OrderReference: "ord-" + order.OrderId(id).String(), + CartId: "cart-abc", + Currency: "SEK", + Locale: "sv-se", + Country: "se", + CustomerEmail: "test@example.com", + TotalAmount: 60000, + TotalTax: 12000, + PlacedAtMs: time.Now().UnixMilli(), + Lines: lines, + } + results, err := a.reg.Apply(context.Background(), g, po) + if err != nil || len(results) == 0 || results[0].Error != nil { + panic(fmt.Sprintf("place order: %v %v", err, results)) + } + a.grains[id] = g + return order.OrderId(id).String() +} + +func (a *testApplier) Get(_ context.Context, id uint64) (*order.OrderGrain, error) { + g, ok := a.grains[id] + if !ok { + return nil, fmt.Errorf("order %d not found", id) + } + return g, nil +} + +func (a *testApplier) Apply(ctx context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[order.OrderGrain], error) { + g, ok := a.grains[id] + if !ok { + return nil, fmt.Errorf("order %d not found", id) + } + results, err := a.reg.Apply(ctx, g, msgs...) + if err != nil { + return nil, err + } + state, err := g.GetCurrentState() + if err != nil { + return nil, err + } + return &actor.MutationResult[order.OrderGrain]{ + Result: *state, //nolint:govet // test helper snapshot, same pattern as SimpleGrainPool + Mutations: results, + }, nil +} + +// call issues a tools/call against the handler and returns the decoded text +// payload (the JSON the tool returned), failing on a tool/transport error. +func call(t *testing.T, h http.Handler, name string, args map[string]any) map[string]any { + t.Helper() + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": map[string]any{"name": name, "arguments": args}, + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body)) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("%s: status %d", name, rec.Code) + } + var resp struct { + Result struct { + IsError bool `json:"isError"` + Content []struct { + Text string `json:"text"` + } `json:"content"` + } `json:"result"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("%s: decode response: %v (%s)", name, err, rec.Body.String()) + } + if resp.Error != nil { + t.Fatalf("%s: rpc error: %s", name, resp.Error.Message) + } + if resp.Result.IsError { + t.Fatalf("%s: tool error: %s", name, resp.Result.Content[0].Text) + } + var out map[string]any + if len(resp.Result.Content) > 0 { + _ = json.Unmarshal([]byte(resp.Result.Content[0].Text), &out) + } + return out +} + +// callWithRaw returns the raw result (including isError) without failing. +type rawResult struct { + IsError bool + Content []struct { + Text string `json:"text"` + } +} + +func callWithRaw(t *testing.T, h http.Handler, name string, args map[string]any) rawResult { + t.Helper() + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": map[string]any{"name": name, "arguments": args}, + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body)) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("%s: status %d", name, rec.Code) + } + var resp struct { + Result rawResult `json:"result"` + Error *struct{ Message string } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("%s: decode: %v", name, err) + } + if resp.Error != nil { + t.Fatalf("%s: rpc error: %s", name, resp.Error.Message) + } + return resp.Result +} + +func TestInitializeAndToolsList(t *testing.T) { + a := newTestApplier() + h := New(a).Handler() + + // Initialize. + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": map[string]any{"protocolVersion": "2025-06-18"}, + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body)) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("initialize: status %d", rec.Code) + } + var initResp struct { + Result struct { + ProtocolVersion string `json:"protocolVersion"` + ServerInfo struct { + Name string `json:"name"` + } `json:"serverInfo"` + } `json:"result"` + } + json.Unmarshal(rec.Body.Bytes(), &initResp) + if initResp.Result.ProtocolVersion != "2025-06-18" || initResp.Result.ServerInfo.Name != "orders" { + t.Fatalf("unexpected initialize: %+v", initResp) + } + + // tools/list. + body2, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": "tools/list", + }) + rec2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body2)) + h.ServeHTTP(rec2, req2) + var listResp struct { + Result struct { + Tools []struct { + Name string `json:"name"` + InputSchema json.RawMessage `json:"inputSchema"` + } `json:"tools"` + } `json:"result"` + } + json.Unmarshal(rec2.Body.Bytes(), &listResp) + want := map[string]bool{ + "get_order": false, "get_order_status": false, + "cancel_order": false, "complete_order": false, + "create_fulfillment": false, "request_return": false, + "issue_refund": false, + } + for _, tl := range listResp.Result.Tools { + if _, ok := want[tl.Name]; ok { + want[tl.Name] = true + } + if len(tl.InputSchema) == 0 { + t.Fatalf("tool %s has empty inputSchema", tl.Name) + } + } + for name, found := range want { + if !found { + t.Fatalf("expected tool %q in tools/list", name) + } + } +} + +func TestGetOrder(t *testing.T) { + a := newTestApplier() + oid := a.addPlacedOrder(42, 2) + h := New(a).Handler() + + got := call(t, h, "get_order", map[string]any{"orderId": oid}) + if got == nil { + t.Fatal("get_order returned nil") + } + if ref, _ := got["orderReference"].(string); ref != "ord-"+oid { + t.Errorf("orderReference = %q, want ord-%s", ref, oid) + } + if curr, _ := got["currency"].(string); curr != "SEK" { + t.Errorf("currency = %q, want SEK", curr) + } + if email, _ := got["customerEmail"].(string); email != "test@example.com" { + t.Errorf("customerEmail = %q, want test@example.com", email) + } +} + +func TestGetOrderStatus(t *testing.T) { + a := newTestApplier() + oid := a.addPlacedOrder(1, 1) + h := New(a).Handler() + + got := call(t, h, "get_order_status", map[string]any{"orderId": oid}) + if s, _ := got["status"].(string); s != "pending" { + t.Errorf("status = %q, want pending", s) + } + transitions, _ := got["nextTransitions"].([]any) + if len(transitions) == 0 { + t.Fatal("expected non-empty nextTransitions") + } +} + +func TestGetOrderLines(t *testing.T) { + a := newTestApplier() + oid := a.addPlacedOrder(1, 3) + h := New(a).Handler() + + got := call(t, h, "get_order_lines", map[string]any{"orderId": oid}) + if c, _ := got["count"].(float64); c != 3 { + t.Errorf("lines count = %v, want 3", got["count"]) + } +} + +func TestGetOrderPayments(t *testing.T) { + a := newTestApplier() + oid := a.addPlacedOrder(1, 1) + // Simulate an authorized + captured payment. + g, _ := a.Get(context.Background(), 1) + g.Status = order.StatusAuthorized + g.Payments = append(g.Payments, &order.Payment{ + Provider: "mock", + Authorized: 60000, + AuthRef: "auth-123", + }) + h := New(a).Handler() + + got := call(t, h, "get_order_payments", map[string]any{"orderId": oid}) + if got == nil { + t.Fatal("get_order_payments returned nil") + } + payments, _ := got["payments"].([]any) + if len(payments) != 1 { + t.Fatalf("want 1 payment, got %d", len(payments)) + } + p := payments[0].(map[string]any) + if prov, _ := p["provider"].(string); prov != "mock" { + t.Errorf("payment provider = %q, want mock", prov) + } +} + +func TestCancelOrder(t *testing.T) { + a := newTestApplier() + oid := a.addPlacedOrder(1, 1) + h := New(a).Handler() + + // Cancel pending order. + got := call(t, h, "cancel_order", map[string]any{"orderId": oid, "reason": "test cancellation"}) + if got == nil { + t.Fatal("cancel_order returned nil") + } + // Verify via get_order. + after := call(t, h, "get_order", map[string]any{"orderId": oid}) + if s, _ := after["status"].(string); s != "cancelled" { + t.Errorf("after cancel: status = %q, want cancelled", s) + } +} + +func TestCreateFulfillment(t *testing.T) { + a := newTestApplier() + oid := a.addPlacedOrder(1, 1) + // Advance to captured state. + h := New(a).Handler() + g, _ := a.Get(context.Background(), 1) + g.Status = order.StatusCaptured + g.CapturedAmount = 60000 + + // Fulfill the only line. + got := call(t, h, "create_fulfillment", map[string]any{ + "orderId": oid, + "carrier": "PostNord", + "trackingNumber": "TN123456", + "lines": []any{ + map[string]any{"reference": "line-1", "quantity": 1}, + }, + }) + if got == nil { + t.Fatal("create_fulfillment returned nil") + } + after := call(t, h, "get_order", map[string]any{"orderId": oid}) + if s, _ := after["status"].(string); s != "fulfilled" { + t.Errorf("after fulfill: status = %q, want fulfilled", s) + } +} + +func TestCompleteOrder(t *testing.T) { + a := newTestApplier() + oid := a.addPlacedOrder(1, 1) + // Advance to fulfilled state. + g, _ := a.Get(context.Background(), 1) + g.Status = order.StatusFulfilled + h := New(a).Handler() + + got := call(t, h, "complete_order", map[string]any{"orderId": oid}) + if got == nil { + t.Fatal("complete_order returned nil") + } + after := call(t, h, "get_order", map[string]any{"orderId": oid}) + if s, _ := after["status"].(string); s != "completed" { + t.Errorf("after complete: status = %q, want completed", s) + } +} + +func TestRequestReturn(t *testing.T) { + a := newTestApplier() + oid := a.addPlacedOrder(1, 2) + // Advance to fulfilled state. + g, _ := a.Get(context.Background(), 1) + g.Status = order.StatusFulfilled + h := New(a).Handler() + + got := call(t, h, "request_return", map[string]any{ + "orderId": oid, + "reason": "defective item", + "lines": []any{ + map[string]any{"reference": "line-1", "quantity": 1}, + }, + }) + if got == nil { + t.Fatal("request_return returned nil") + } + after := call(t, h, "get_order", map[string]any{"orderId": oid}) + returns, _ := after["returns"].([]any) + if len(returns) != 1 { + t.Errorf("after return: want 1 return, got %d", len(returns)) + } +} + +func TestIssueRefund(t *testing.T) { + a := newTestApplier() + oid := a.addPlacedOrder(1, 1) + // Advance to captured state. + g, _ := a.Get(context.Background(), 1) + g.Status = order.StatusCaptured + g.CapturedAmount = 60000 + h := New(a).Handler() + + got := call(t, h, "issue_refund", map[string]any{"orderId": oid, "amount": 30000}) + if got == nil { + t.Fatal("issue_refund returned nil") + } + after := call(t, h, "get_order", map[string]any{"orderId": oid}) + if r, _ := after["refundedAmount"].(float64); r != 30000 { + t.Errorf("refundedAmount = %v, want 30000", r) + } +} + +func TestOrderMCPErrors(t *testing.T) { + a := newTestApplier() + oid := a.addPlacedOrder(1, 1) + h := New(a).Handler() + + // Invalid order id -> isError. + result := callWithRaw(t, h, "get_order", map[string]any{"orderId": "!!!"}) + if !result.IsError { + t.Fatal("expected isError for invalid order id") + } + + // Non-existent order -> isError. + result2 := callWithRaw(t, h, "get_order", map[string]any{"orderId": "zzzzzz"}) + if !result2.IsError { + t.Fatal("expected isError for non-existent order") + } + + // Cancel wrong state (already cancelled) -> isError. + call(t, h, "cancel_order", map[string]any{"orderId": oid, "reason": "cancel"}) + result3 := callWithRaw(t, h, "cancel_order", map[string]any{"orderId": oid, "reason": "double cancel"}) + if !result3.IsError { + t.Fatal("expected isError for cancelling already-cancelled order") + } + + // Unknown tool -> protocol error. + var resp struct { + Error *struct{ Message string } `json:"error"` + } + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": map[string]any{"name": "nope", "arguments": map[string]any{}}, + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body)) + h.ServeHTTP(rec, req) + json.Unmarshal(rec.Body.Bytes(), &resp) + if resp.Error == nil { + t.Fatal("expected protocol error for unknown tool") + } +} diff --git a/pkg/order/mcp/tools.go b/pkg/order/mcp/tools.go new file mode 100644 index 0000000..365fc50 --- /dev/null +++ b/pkg/order/mcp/tools.go @@ -0,0 +1,519 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "git.k6n.net/mats/go-cart-actor/pkg/order" + messages "git.k6n.net/mats/go-cart-actor/proto/order" +) + +// tool is one MCP tool: a name, a description, a JSON-Schema for its arguments, +// and the handler that runs it. +type tool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema json.RawMessage `json:"inputSchema"` + invoke func(args json.RawMessage) (any, error) `json:"-"` +} + +func (s *Server) callTool(req *rpcRequest) *rpcResponse { + var p struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + } + if err := json.Unmarshal(req.Params, &p); err != nil { + return errorResponse(req.ID, codeInvalidParams, "invalid params") + } + var t *tool + for i := range s.tools { + if s.tools[i].Name == p.Name { + t = &s.tools[i] + break + } + } + if t == nil { + return errorResponse(req.ID, codeInvalidParams, "unknown tool: "+p.Name) + } + out, err := t.invoke(p.Arguments) + if err != nil { + return result(req.ID, toolError(err)) + } + return result(req.ID, toolText(out)) +} + +func (s *Server) buildTools() []tool { + return []tool{ + { + Name: "get_order", + Description: "Get the full detail of an order by its base62 order id: status, line items, payments, fulfillments, returns, refunds, customer info, billing/shipping addresses, and amounts.", + InputSchema: object(props{ + "orderId": str("the base62 order id"), + }, []string{"orderId"}), + invoke: func(args json.RawMessage) (any, error) { + var a struct { + OrderID string `json:"orderId"` + } + if err := decode(args, &a); err != nil { + return nil, err + } + id, ok := order.ParseOrderId(a.OrderID) + if !ok { + return nil, fmt.Errorf("invalid order id %q", a.OrderID) + } + g, err := s.applier.Get(context.Background(), uint64(id)) + if err != nil { + return nil, fmt.Errorf("get order: %w", err) + } + if g.Status == order.StatusNew { + return nil, fmt.Errorf("order %q not found", a.OrderID) + } + return g, nil + }, + }, + { + Name: "get_order_status", + Description: "Get the current status (new|pending|authorized|captured|partially_fulfilled|fulfilled|completed|cancelled|refunded) of an order and the legal next transitions.", + InputSchema: object(props{ + "orderId": str("the base62 order id"), + }, []string{"orderId"}), + invoke: func(args json.RawMessage) (any, error) { + var a struct { + OrderID string `json:"orderId"` + } + if err := decode(args, &a); err != nil { + return nil, err + } + id, ok := order.ParseOrderId(a.OrderID) + if !ok { + return nil, fmt.Errorf("invalid order id %q", a.OrderID) + } + g, err := s.applier.Get(context.Background(), uint64(id)) + if err != nil { + return nil, fmt.Errorf("get order: %w", err) + } + if g.Status == order.StatusNew { + return nil, fmt.Errorf("order %q not found", a.OrderID) + } + return map[string]any{ + "orderId": a.OrderID, + "status": string(g.Status), + "nextTransitions": validTransitions(g.Status), + }, nil + }, + }, + { + Name: "get_order_lines", + Description: "List all line items on an order with reference, SKU, name, quantity, unit price, tax rate, total amount, total tax, and fulfilled quantity.", + InputSchema: object(props{ + "orderId": str("the base62 order id"), + }, []string{"orderId"}), + invoke: func(args json.RawMessage) (any, error) { + var a struct { + OrderID string `json:"orderId"` + } + if err := decode(args, &a); err != nil { + return nil, err + } + id, ok := order.ParseOrderId(a.OrderID) + if !ok { + return nil, fmt.Errorf("invalid order id %q", a.OrderID) + } + g, err := s.applier.Get(context.Background(), uint64(id)) + if err != nil { + return nil, fmt.Errorf("get order: %w", err) + } + if g.Status == order.StatusNew { + return nil, fmt.Errorf("order %q not found", a.OrderID) + } + return map[string]any{"lines": g.Lines, "count": len(g.Lines)}, nil + }, + }, + { + Name: "get_order_payments", + Description: "List all payment records on an order: provider, authorized/captured/refunded amounts, authorization and capture references.", + InputSchema: object(props{ + "orderId": str("the base62 order id"), + }, []string{"orderId"}), + invoke: func(args json.RawMessage) (any, error) { + var a struct { + OrderID string `json:"orderId"` + } + if err := decode(args, &a); err != nil { + return nil, err + } + id, ok := order.ParseOrderId(a.OrderID) + if !ok { + return nil, fmt.Errorf("invalid order id %q", a.OrderID) + } + g, err := s.applier.Get(context.Background(), uint64(id)) + if err != nil { + return nil, fmt.Errorf("get order: %w", err) + } + if g.Status == order.StatusNew { + return nil, fmt.Errorf("order %q not found", a.OrderID) + } + return map[string]any{"payments": g.Payments, "capturedAmount": g.CapturedAmount, "refundedAmount": g.RefundedAmount}, nil + }, + }, + { + Name: "get_order_fulfillments", + Description: "List all fulfillments (shipments) on an order: carrier, tracking number/URI, shipped lines with quantities, and creation date.", + InputSchema: object(props{ + "orderId": str("the base62 order id"), + }, []string{"orderId"}), + invoke: func(args json.RawMessage) (any, error) { + var a struct { + OrderID string `json:"orderId"` + } + if err := decode(args, &a); err != nil { + return nil, err + } + id, ok := order.ParseOrderId(a.OrderID) + if !ok { + return nil, fmt.Errorf("invalid order id %q", a.OrderID) + } + g, err := s.applier.Get(context.Background(), uint64(id)) + if err != nil { + return nil, fmt.Errorf("get order: %w", err) + } + if g.Status == order.StatusNew { + return nil, fmt.Errorf("order %q not found", a.OrderID) + } + return map[string]any{"fulfillments": g.Fulfillments, "count": len(g.Fulfillments)}, nil + }, + }, + { + Name: "get_order_returns", + Description: "List all return requests (RMAs) on an order: reason, returned lines, and request date.", + InputSchema: object(props{ + "orderId": str("the base62 order id"), + }, []string{"orderId"}), + invoke: func(args json.RawMessage) (any, error) { + var a struct { + OrderID string `json:"orderId"` + } + if err := decode(args, &a); err != nil { + return nil, err + } + id, ok := order.ParseOrderId(a.OrderID) + if !ok { + return nil, fmt.Errorf("invalid order id %q", a.OrderID) + } + g, err := s.applier.Get(context.Background(), uint64(id)) + if err != nil { + return nil, fmt.Errorf("get order: %w", err) + } + if g.Status == order.StatusNew { + return nil, fmt.Errorf("order %q not found", a.OrderID) + } + return map[string]any{"returns": g.Returns, "count": len(g.Returns)}, nil + }, + }, + { + Name: "cancel_order", + Description: "Cancel an order that is still in a cancellable state (pending or authorized) with an optional reason. Returns the updated order.", + InputSchema: object(props{ + "orderId": str("the base62 order id"), + "reason": str("optional reason for cancellation"), + }, []string{"orderId"}), + invoke: func(args json.RawMessage) (any, error) { + var a struct { + OrderID string `json:"orderId"` + Reason string `json:"reason"` + } + if err := decode(args, &a); err != nil { + return nil, err + } + id, ok := order.ParseOrderId(a.OrderID) + if !ok { + return nil, fmt.Errorf("invalid order id %q", a.OrderID) + } + res, err := s.applier.Apply(context.Background(), uint64(id), &messages.CancelOrder{ + Reason: a.Reason, + AtMs: time.Now().UnixMilli(), + }) + if err != nil { + return nil, fmt.Errorf("cancel order: %w", err) + } + for _, m := range res.Mutations { + if m.Error != nil { + return nil, m.Error + } + } + return map[string]any{"orderId": a.OrderID, "order": &res.Result}, nil + }, + }, + { + Name: "create_fulfillment", + Description: "Ship lines on a captured order. Provide carrier and optionally tracking info. Each line entry requires a reference (the line reference) and quantity to ship. Returns the updated order.", + InputSchema: object(props{ + "orderId": str("the base62 order id"), + "carrier": str("the shipping carrier name (e.g. PostNord, DHL)"), + "trackingNumber": str("optional tracking number"), + "trackingUri": str("optional tracking URL"), + "lines": objArray("lines to fulfill, each with reference (string) and quantity (integer)"), + }, []string{"orderId", "lines"}), + invoke: func(args json.RawMessage) (any, error) { + var a struct { + OrderID string `json:"orderId"` + Carrier string `json:"carrier"` + TrackingNumber string `json:"trackingNumber"` + TrackingURI string `json:"trackingUri"` + Lines []struct { + Reference string `json:"reference"` + Quantity int32 `json:"quantity"` + } `json:"lines"` + } + if err := decode(args, &a); err != nil { + return nil, err + } + id, ok := order.ParseOrderId(a.OrderID) + if !ok { + return nil, fmt.Errorf("invalid order id %q", a.OrderID) + } + fid, _ := order.NewOrderId() + fulfillmentLines := make([]*messages.FulfillmentLine, len(a.Lines)) + for i, l := range a.Lines { + fulfillmentLines[i] = &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity} + } + res, err := s.applier.Apply(context.Background(), uint64(id), &messages.CreateFulfillment{ + Id: "f_" + fid.String(), + Carrier: a.Carrier, + TrackingNumber: a.TrackingNumber, + TrackingUri: a.TrackingURI, + Lines: fulfillmentLines, + AtMs: time.Now().UnixMilli(), + }) + if err != nil { + return nil, fmt.Errorf("create fulfillment: %w", err) + } + for _, m := range res.Mutations { + if m.Error != nil { + return nil, m.Error + } + } + return map[string]any{"orderId": a.OrderID, "order": &res.Result}, nil + }, + }, + { + Name: "complete_order", + Description: "Mark a fulfilled order as completed. Only allowed when the order is in 'fulfilled' status. Returns the updated order.", + InputSchema: object(props{ + "orderId": str("the base62 order id"), + }, []string{"orderId"}), + invoke: func(args json.RawMessage) (any, error) { + var a struct { + OrderID string `json:"orderId"` + } + if err := decode(args, &a); err != nil { + return nil, err + } + id, ok := order.ParseOrderId(a.OrderID) + if !ok { + return nil, fmt.Errorf("invalid order id %q", a.OrderID) + } + res, err := s.applier.Apply(context.Background(), uint64(id), &messages.CompleteOrder{ + AtMs: time.Now().UnixMilli(), + }) + if err != nil { + return nil, fmt.Errorf("complete order: %w", err) + } + for _, m := range res.Mutations { + if m.Error != nil { + return nil, m.Error + } + } + return map[string]any{"orderId": a.OrderID, "order": &res.Result}, nil + }, + }, + { + Name: "request_return", + Description: "Open a return request (RMA) against fulfilled lines on an order. Provide the lines being returned and a reason. Returns the updated order with the return recorded.", + InputSchema: object(props{ + "orderId": str("the base62 order id"), + "reason": str("the reason for the return"), + "lines": objArray("lines being returned, each with reference (string) and quantity (integer)"), + }, []string{"orderId", "reason", "lines"}), + invoke: func(args json.RawMessage) (any, error) { + var a struct { + OrderID string `json:"orderId"` + Reason string `json:"reason"` + Lines []struct { + Reference string `json:"reference"` + Quantity int32 `json:"quantity"` + } `json:"lines"` + } + if err := decode(args, &a); err != nil { + return nil, err + } + id, ok := order.ParseOrderId(a.OrderID) + if !ok { + return nil, fmt.Errorf("invalid order id %q", a.OrderID) + } + rid, _ := order.NewOrderId() + returnLines := make([]*messages.FulfillmentLine, len(a.Lines)) + for i, l := range a.Lines { + returnLines[i] = &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity} + } + res, err := s.applier.Apply(context.Background(), uint64(id), &messages.RequestReturn{ + Id: "r_" + rid.String(), + Reason: a.Reason, + Lines: returnLines, + AtMs: time.Now().UnixMilli(), + }) + if err != nil { + return nil, fmt.Errorf("request return: %w", err) + } + for _, m := range res.Mutations { + if m.Error != nil { + return nil, m.Error + } + } + return map[string]any{"orderId": a.OrderID, "order": &res.Result}, nil + }, + }, + { + Name: "issue_refund", + Description: "Issue a refund against a captured order. Amount is in minor units (öre); omit for the full remaining captured amount. Returns the updated order.", + InputSchema: object(props{ + "orderId": str("the base62 order id"), + "amount": integer("refund amount in minor units (öre); omit for full remaining captured amount"), + }, []string{"orderId"}), + invoke: func(args json.RawMessage) (any, error) { + var a struct { + OrderID string `json:"orderId"` + Amount int64 `json:"amount"` + } + if err := decode(args, &a); err != nil { + return nil, err + } + id, ok := order.ParseOrderId(a.OrderID) + if !ok { + return nil, fmt.Errorf("invalid order id %q", a.OrderID) + } + // If amount is 0 (omitted), default to full remaining captured amount. + if a.Amount <= 0 { + g, err := s.applier.Get(context.Background(), uint64(id)) + if err != nil { + return nil, fmt.Errorf("get order for refund: %w", err) + } + if g.Status == order.StatusNew { + return nil, fmt.Errorf("order %q not found", a.OrderID) + } + a.Amount = g.CapturedAmount - g.RefundedAmount + } + if a.Amount <= 0 { + return nil, fmt.Errorf("refund amount must be positive") + } + res, err := s.applier.Apply(context.Background(), uint64(id), &messages.IssueRefund{ + Provider: "mcp", + Amount: a.Amount, + Reference: "ref-mcp-" + a.OrderID, + AtMs: time.Now().UnixMilli(), + }) + if err != nil { + return nil, fmt.Errorf("issue refund: %w", err) + } + for _, m := range res.Mutations { + if m.Error != nil { + return nil, m.Error + } + } + return map[string]any{"orderId": a.OrderID, "order": &res.Result}, nil + }, + }, + } +} + +// validTransitions returns the list of legal next statuses for a given status, +// matching the transitions map in the order state machine. +func validTransitions(s order.Status) []string { + switch s { + case order.StatusNew: + return []string{"pending"} + case order.StatusPending: + return []string{"authorized", "cancelled"} + case order.StatusAuthorized: + return []string{"captured", "cancelled"} + case order.StatusCaptured: + return []string{"partially_fulfilled", "fulfilled", "refunded"} + case order.StatusPartiallyFulfilled: + return []string{"partially_fulfilled", "fulfilled", "refunded"} + case order.StatusFulfilled: + return []string{"completed", "refunded"} + case order.StatusCompleted: + return []string{"refunded"} + default: + return []string{} + } +} + +// ---- result + schema helpers ----------------------------------------------- + +func toolText(v any) map[string]any { + b, err := json.Marshal(v) + if err != nil { + return toolError(err) + } + return map[string]any{ + "content": []map[string]any{{"type": "text", "text": string(b)}}, + } +} + +func toolError(err error) map[string]any { + return map[string]any{ + "isError": true, + "content": []map[string]any{{"type": "text", "text": err.Error()}}, + } +} + +// decode unmarshals tool arguments, tolerating empty/absent arguments. +func decode(args json.RawMessage, v any) error { + if len(args) == 0 || string(args) == "null" { + return nil + } + if err := json.Unmarshal(args, v); err != nil { + return fmt.Errorf("invalid arguments: %w", err) + } + return nil +} + +type props map[string]json.RawMessage + +func object(p props, required []string) json.RawMessage { + m := map[string]any{ + "type": "object", + "properties": p, + } + if len(required) > 0 { + m["required"] = required + } + b, _ := json.Marshal(m) + return b +} + +func str(desc string) json.RawMessage { return scalar("string", desc) } +func integer(d string) json.RawMessage { return scalar("integer", d) } +func obj(desc string) json.RawMessage { return scalar("object", desc) } + +func scalar(typ, desc string) json.RawMessage { + b, _ := json.Marshal(map[string]any{"type": typ, "description": desc}) + return b +} + +func objArray(desc string) json.RawMessage { + b, _ := json.Marshal(map[string]any{ + "type": "array", "description": desc, "items": map[string]string{"type": "object"}, + }) + return b +} + +func stringArray(desc string) json.RawMessage { + b, _ := json.Marshal(map[string]any{ + "type": "array", "description": desc, "items": map[string]string{"type": "string"}, + }) + return b +}