mcp and ucp
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
|||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
||||||
promotionmcp "git.k6n.net/mats/go-cart-actor/pkg/promotions/mcp"
|
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/proxy"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
|
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
|
||||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||||
@@ -66,6 +67,26 @@ func normalizeListenAddr(v string) string {
|
|||||||
return ":" + v
|
return ":" + v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadUCPCartSigner loads the UCP ECDSA signing key from the path specified in
|
||||||
|
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
|
||||||
|
func loadUCPCartSigner() *ucp.SigningConfig {
|
||||||
|
path := os.Getenv("UCP_SIGNING_KEY_PATH")
|
||||||
|
if path == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pemData, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ucp signing: cannot read key %s: %v", path, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cfg, err := ucp.NewSigningConfigFromPEM(pemData, "k6n-ecdsa-2026")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ucp signing: invalid key at %s: %v", path, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
func getCountryFromHost(host string) string {
|
func getCountryFromHost(host string) string {
|
||||||
if strings.Contains(strings.ToLower(host), "-no") {
|
if strings.Contains(strings.ToLower(host), "-no") {
|
||||||
return "no"
|
return "no"
|
||||||
@@ -278,6 +299,15 @@ func main() {
|
|||||||
mux.Handle("/mcp", promotionMCP.Handler())
|
mux.Handle("/mcp", promotionMCP.Handler())
|
||||||
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
|
// Stateless promotion evaluation: POST a (possibly partial) eval context and
|
||||||
// get back the resulting totals plus the applied/pending effect breakdown,
|
// get back the resulting totals plus the applied/pending effect breakdown,
|
||||||
// without creating or mutating a real cart.
|
// without creating or mutating a real cart.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"time"
|
"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/actor"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
"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 redisPassword = os.Getenv("REDIS_PASSWORD")
|
||||||
var cartInternalUrl = os.Getenv("CART_INTERNAL_URL") // e.g., http://cart-service:8081
|
var cartInternalUrl = os.Getenv("CART_INTERNAL_URL") // e.g., http://cart-service:8081
|
||||||
|
|
||||||
|
// loadUCPCheckoutSigner loads the UCP ECDSA signing key from the path specified in
|
||||||
|
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
|
||||||
|
func loadUCPCheckoutSigner() *ucp.SigningConfig {
|
||||||
|
path := os.Getenv("UCP_SIGNING_KEY_PATH")
|
||||||
|
if path == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pemData, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ucp signing: cannot read key %s: %v", path, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cfg, err := ucp.NewSigningConfigFromPEM(pemData, "k6n-ecdsa-2026")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ucp signing: invalid key at %s: %v", path, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
||||||
controlPlaneConfig := actor.DefaultServerConfig()
|
controlPlaneConfig := actor.DefaultServerConfig()
|
||||||
@@ -160,6 +181,22 @@ func main() {
|
|||||||
|
|
||||||
syncedServer.Serve(mux)
|
syncedServer.Serve(mux)
|
||||||
|
|
||||||
|
// UCP REST adapter — Universal Commerce Protocol checkout sessions
|
||||||
|
// When an order service URL is configured, the complete endpoint creates
|
||||||
|
// real orders via the order service's from-checkout API.
|
||||||
|
var ucpOrderSvc ucp.OrderApplier
|
||||||
|
if orderServiceURL := os.Getenv("ORDER_SERVICE_URL"); orderServiceURL != "" {
|
||||||
|
ucpOrderSvc = ucp.NewOrderHTTPClient(orderServiceURL)
|
||||||
|
log.Printf("ucp order applier enabled: %s", orderServiceURL)
|
||||||
|
}
|
||||||
|
checkoutUCP := ucp.CheckoutHandler(pool, ucpOrderSvc)
|
||||||
|
if signer := loadUCPCheckoutSigner(); signer != nil {
|
||||||
|
checkoutUCP = ucp.WithSigning(checkoutUCP, signer)
|
||||||
|
log.Print("ucp signing enabled")
|
||||||
|
}
|
||||||
|
mux.Handle("/ucp/v1/checkout-sessions", checkoutUCP)
|
||||||
|
mux.Handle("/ucp/v1/checkout-sessions/", checkoutUCP)
|
||||||
|
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
grainCount, capacity := pool.LocalUsage()
|
grainCount, capacity := pool.LocalUsage()
|
||||||
if grainCount >= capacity {
|
if grainCount >= capacity {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"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/actor"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/idempotency"
|
"git.k6n.net/mats/go-cart-actor/pkg/idempotency"
|
||||||
@@ -155,6 +156,15 @@ func main() {
|
|||||||
mux.HandleFunc("POST /api/orders/{id}/returns", s.handleReturn)
|
mux.HandleFunc("POST /api/orders/{id}/returns", s.handleReturn)
|
||||||
mux.HandleFunc("POST /api/orders/{id}/refunds", s.handleRefund)
|
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).
|
// Saga / flow admin (backoffice editor).
|
||||||
mux.HandleFunc("GET /sagas/capabilities", s.handleCapabilities)
|
mux.HandleFunc("GET /sagas/capabilities", s.handleCapabilities)
|
||||||
mux.HandleFunc("GET /sagas/flows", s.handleListFlows)
|
mux.HandleFunc("GET /sagas/flows", s.handleListFlows)
|
||||||
@@ -444,6 +454,26 @@ func selectProvider(logger *slog.Logger) order.PaymentProvider {
|
|||||||
return order.NewMockProvider()
|
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 {
|
func envOr(key, def string) string {
|
||||||
if v := os.Getenv(key); v != "" {
|
if v := os.Getenv(key); v != "" {
|
||||||
return v
|
return v
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package ucp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
|
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testApplier implements CartApplier with an in-memory map of grains.
|
||||||
|
type testApplier struct {
|
||||||
|
grains map[uint64]*cart.CartGrain
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestApplier() *testApplier {
|
||||||
|
return &testApplier{
|
||||||
|
grains: make(map[uint64]*cart.CartGrain),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *testApplier) Get(_ context.Context, id uint64) (*cart.CartGrain, error) {
|
||||||
|
g, ok := a.grains[id]
|
||||||
|
if !ok {
|
||||||
|
g = cart.NewCartGrain(id, time.UnixMilli(1000000))
|
||||||
|
a.grains[id] = g
|
||||||
|
}
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *testApplier) Apply(_ context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[cart.CartGrain], error) {
|
||||||
|
g, err := a.Get(nil, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results := make([]actor.ApplyResult, len(msgs))
|
||||||
|
for i, msg := range msgs {
|
||||||
|
results[i] = actor.ApplyResult{Type: string(msg.ProtoReflect().Descriptor().FullName())}
|
||||||
|
switch m := msg.(type) {
|
||||||
|
case *messages.ClearCartRequest:
|
||||||
|
g.Items = nil
|
||||||
|
g.Vouchers = nil
|
||||||
|
g.TotalDiscount = cart.NewPrice()
|
||||||
|
g.TotalPrice = cart.NewPrice()
|
||||||
|
case *messages.SetUserId:
|
||||||
|
// userId is unexported; rely on serialization to verify.
|
||||||
|
_ = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &actor.MutationResult[cart.CartGrain]{
|
||||||
|
Result: *g,
|
||||||
|
Mutations: results,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustParseID returns the uint64 representation of a base62 cart id string.
|
||||||
|
func mustParseID(s string) uint64 {
|
||||||
|
id, _ := cart.ParseCartId(s)
|
||||||
|
return uint64(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCartHandler_CreateCart(t *testing.T) {
|
||||||
|
applier := newTestApplier()
|
||||||
|
handler := CartHandler(applier)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("expected 201 Created, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp CartResponse
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Id == "" {
|
||||||
|
t.Fatal("expected non-empty cart id")
|
||||||
|
}
|
||||||
|
if resp.Status != "active" {
|
||||||
|
t.Fatalf("expected status 'active', got %q", resp.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCartHandler_GetCartNotFound(t *testing.T) {
|
||||||
|
applier := newTestApplier()
|
||||||
|
handler := CartHandler(applier)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/nonexistent", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
// /nonexistent is a valid base62 string, so it parses, but the cart
|
||||||
|
// auto-creates on first Get, so we get a 200 with an empty cart.
|
||||||
|
// This is expected behavior (UCP auto-creates carts on read).
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 (auto-create), got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCartHandler_CreateAndGet(t *testing.T) {
|
||||||
|
applier := newTestApplier()
|
||||||
|
handler := CartHandler(applier)
|
||||||
|
|
||||||
|
// Create a cart.
|
||||||
|
req := httptest.NewRequest("POST", "/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create failed: %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var created CartResponse
|
||||||
|
json.NewDecoder(rec.Body).Decode(&created)
|
||||||
|
|
||||||
|
// GET it back.
|
||||||
|
req = httptest.NewRequest("GET", "/"+created.Id, nil)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got CartResponse
|
||||||
|
json.NewDecoder(rec.Body).Decode(&got)
|
||||||
|
if got.Id != created.Id {
|
||||||
|
t.Fatalf("expected id %q, got %q", created.Id, got.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCartHandler_CancelCart(t *testing.T) {
|
||||||
|
applier := newTestApplier()
|
||||||
|
handler := CartHandler(applier)
|
||||||
|
|
||||||
|
// Create a cart.
|
||||||
|
req := httptest.NewRequest("POST", "/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
var created CartResponse
|
||||||
|
json.NewDecoder(rec.Body).Decode(&created)
|
||||||
|
|
||||||
|
// Cancel it.
|
||||||
|
req = httptest.NewRequest("POST", "/"+created.Id+"/cancel", nil)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cancelled CartResponse
|
||||||
|
json.NewDecoder(rec.Body).Decode(&cancelled)
|
||||||
|
if cancelled.Id != created.Id {
|
||||||
|
t.Fatalf("expected same id %q, got %q", created.Id, cancelled.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCartHandler_UpdateCart(t *testing.T) {
|
||||||
|
applier := newTestApplier()
|
||||||
|
handler := CartHandler(applier)
|
||||||
|
|
||||||
|
// Create a cart.
|
||||||
|
req := httptest.NewRequest("POST", "/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
var created CartResponse
|
||||||
|
json.NewDecoder(rec.Body).Decode(&created)
|
||||||
|
|
||||||
|
// Update with items (test applier doesn't process AddItem, but the
|
||||||
|
// handler still returns 200 — items are populated by the real grain pool).
|
||||||
|
updateBody := `{"items": [{"sku": "test-sku", "quantity": 2, "name": "Test Item"}]}`
|
||||||
|
req = httptest.NewRequest("PUT", "/"+created.Id, strings.NewReader(updateBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated CartResponse
|
||||||
|
json.NewDecoder(rec.Body).Decode(&updated)
|
||||||
|
if updated.Id != created.Id {
|
||||||
|
t.Fatalf("expected same id %q, got %q", created.Id, updated.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCartResponse_Conversion(t *testing.T) {
|
||||||
|
id := mustParseID("ABCD")
|
||||||
|
g := cart.NewCartGrain(id, time.UnixMilli(1000000))
|
||||||
|
g.Currency = "SEK"
|
||||||
|
g.Items = append(g.Items, &cart.CartItem{
|
||||||
|
Sku: "test-1",
|
||||||
|
Quantity: 2,
|
||||||
|
Price: *mustPrice(10000, 2500),
|
||||||
|
TotalPrice: *mustPrice(20000, 5000),
|
||||||
|
Tax: 2500,
|
||||||
|
Meta: &cart.ItemMeta{Name: "Test Item"},
|
||||||
|
})
|
||||||
|
g.TotalPrice = mustPrice(20000, 5000)
|
||||||
|
|
||||||
|
resp := cartGrainToResponse(g.Id.String(), g)
|
||||||
|
if len(resp.Items) != 1 {
|
||||||
|
t.Fatalf("expected 1 item, got %d", len(resp.Items))
|
||||||
|
}
|
||||||
|
if resp.Items[0].Sku != "test-1" {
|
||||||
|
t.Fatalf("expected sku 'test-1', got %q", resp.Items[0].Sku)
|
||||||
|
}
|
||||||
|
if resp.Items[0].Name != "Test Item" {
|
||||||
|
t.Fatalf("expected name 'Test Item', got %q", resp.Items[0].Name)
|
||||||
|
}
|
||||||
|
if resp.Totals.TotalIncVat != 20000 {
|
||||||
|
t.Fatalf("expected total 20000, got %d", resp.Totals.TotalIncVat)
|
||||||
|
}
|
||||||
|
if resp.Totals.Currency != "SEK" {
|
||||||
|
t.Fatalf("expected currency SEK, got %q", resp.Totals.Currency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustPrice(incVat int64, totalVat int64) *cart.Price {
|
||||||
|
return &cart.Price{
|
||||||
|
IncVat: incVat,
|
||||||
|
VatRates: map[float32]int64{25: totalVat},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
package ucp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||||
|
orderMessages "git.k6n.net/mats/go-cart-actor/proto/order"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OrderServer is the UCP REST adapter over the order grain pool.
|
||||||
|
type OrderServer struct {
|
||||||
|
applier OrderReadApplier
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOrderServer builds a UCP REST adapter over an order grain pool.
|
||||||
|
func NewOrderServer(applier OrderReadApplier) *OrderServer {
|
||||||
|
return &OrderServer{applier: applier}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UCP Order endpoints
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// handleGetOrder handles GET /orders/{id}.
|
||||||
|
func (s *OrderServer) handleGetOrder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
g, err := s.loadOrder(w, r)
|
||||||
|
if err != nil || g == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), g))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCancelOrder handles POST /orders/{id}/cancel.
|
||||||
|
func (s *OrderServer) handleCancelOrder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
g, err := s.loadOrder(w, r)
|
||||||
|
if err != nil || g == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
|
res, applyErr := s.applier.Apply(r.Context(), g.Id, &orderMessages.CancelOrder{
|
||||||
|
Reason: req.Reason,
|
||||||
|
AtMs: time.Now().UnixMilli(),
|
||||||
|
})
|
||||||
|
if applyErr != nil || res == nil || mutationRejected(res) {
|
||||||
|
msg := "failed to cancel order"
|
||||||
|
if res != nil && len(res.Mutations) > 0 && res.Mutations[0].Error != nil {
|
||||||
|
msg += ": " + res.Mutations[0].Error.Error()
|
||||||
|
} else if applyErr != nil {
|
||||||
|
msg += ": " + applyErr.Error()
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusUnprocessableEntity, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, _ := s.applier.Get(r.Context(), g.Id)
|
||||||
|
if updated != nil {
|
||||||
|
writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), updated))
|
||||||
|
} else {
|
||||||
|
writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), g))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateFulfillment handles POST /orders/{id}/fulfillments.
|
||||||
|
func (s *OrderServer) handleCreateFulfillment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
g, err := s.loadOrder(w, r)
|
||||||
|
if err != nil || g == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Carrier string `json:"carrier,omitempty"`
|
||||||
|
TrackingNumber string `json:"trackingNumber,omitempty"`
|
||||||
|
TrackingURI string `json:"trackingUri,omitempty"`
|
||||||
|
Lines []struct {
|
||||||
|
Reference string `json:"reference"`
|
||||||
|
Quantity int32 `json:"quantity"`
|
||||||
|
} `json:"lines,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fid, _ := order.NewOrderId()
|
||||||
|
msg := &orderMessages.CreateFulfillment{
|
||||||
|
Id: "f_" + fid.String(),
|
||||||
|
Carrier: req.Carrier,
|
||||||
|
TrackingNumber: req.TrackingNumber,
|
||||||
|
TrackingUri: req.TrackingURI,
|
||||||
|
AtMs: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
for _, l := range req.Lines {
|
||||||
|
msg.Lines = append(msg.Lines, &orderMessages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity})
|
||||||
|
}
|
||||||
|
|
||||||
|
res, applyErr := s.applier.Apply(r.Context(), g.Id, msg)
|
||||||
|
if applyErr != nil || res == nil || mutationRejected(res) {
|
||||||
|
msg := "failed to create fulfillment"
|
||||||
|
if res != nil && len(res.Mutations) > 0 && res.Mutations[0].Error != nil {
|
||||||
|
msg += ": " + res.Mutations[0].Error.Error()
|
||||||
|
} else if applyErr != nil {
|
||||||
|
msg += ": " + applyErr.Error()
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusUnprocessableEntity, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, _ := s.applier.Get(r.Context(), g.Id)
|
||||||
|
writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRequestReturn handles POST /orders/{id}/returns.
|
||||||
|
func (s *OrderServer) handleRequestReturn(w http.ResponseWriter, r *http.Request) {
|
||||||
|
g, err := s.loadOrder(w, r)
|
||||||
|
if err != nil || g == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Lines []struct {
|
||||||
|
Reference string `json:"reference"`
|
||||||
|
Quantity int32 `json:"quantity"`
|
||||||
|
} `json:"lines,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rid, _ := order.NewOrderId()
|
||||||
|
msg := &orderMessages.RequestReturn{
|
||||||
|
Id: "r_" + rid.String(),
|
||||||
|
Reason: req.Reason,
|
||||||
|
AtMs: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
for _, l := range req.Lines {
|
||||||
|
msg.Lines = append(msg.Lines, &orderMessages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity})
|
||||||
|
}
|
||||||
|
|
||||||
|
res, applyErr := s.applier.Apply(r.Context(), g.Id, msg)
|
||||||
|
if applyErr != nil || res == nil || mutationRejected(res) {
|
||||||
|
msg := "failed to request return"
|
||||||
|
if res != nil && len(res.Mutations) > 0 && res.Mutations[0].Error != nil {
|
||||||
|
msg += ": " + res.Mutations[0].Error.Error()
|
||||||
|
} else if applyErr != nil {
|
||||||
|
msg += ": " + applyErr.Error()
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusUnprocessableEntity, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, _ := s.applier.Get(r.Context(), g.Id)
|
||||||
|
writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleIssueRefund handles POST /orders/{id}/refunds.
|
||||||
|
func (s *OrderServer) handleIssueRefund(w http.ResponseWriter, r *http.Request) {
|
||||||
|
g, err := s.loadOrder(w, r)
|
||||||
|
if err != nil || g == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Amount int64 `json:"amount,omitempty"` // minor units; omit for full remaining
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
|
amount := req.Amount
|
||||||
|
if amount <= 0 {
|
||||||
|
amount = g.CapturedAmount - g.RefundedAmount
|
||||||
|
}
|
||||||
|
if amount <= 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "no captured amount to refund")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idStr := order.OrderId(g.Id).String()
|
||||||
|
res, applyErr := s.applier.Apply(r.Context(), g.Id, &orderMessages.IssueRefund{
|
||||||
|
Provider: "ucp",
|
||||||
|
Amount: amount,
|
||||||
|
Reference: "ref-ucp-" + idStr,
|
||||||
|
AtMs: time.Now().UnixMilli(),
|
||||||
|
})
|
||||||
|
if applyErr != nil || res == nil || mutationRejected(res) {
|
||||||
|
msg := "failed to issue refund"
|
||||||
|
if res != nil && len(res.Mutations) > 0 && res.Mutations[0].Error != nil {
|
||||||
|
msg += ": " + res.Mutations[0].Error.Error()
|
||||||
|
} else if applyErr != nil {
|
||||||
|
msg += ": " + applyErr.Error()
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusUnprocessableEntity, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, _ := s.applier.Get(r.Context(), g.Id)
|
||||||
|
writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// mutationRejected returns true if the Apply result indicates the mutation was
|
||||||
|
// rejected (business-rule violation rather than infrastructure error).
|
||||||
|
func mutationRejected(res *actor.MutationResult[order.OrderGrain]) bool {
|
||||||
|
return len(res.Mutations) > 0 && res.Mutations[0].Error != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadOrder parses the id from the path and returns the grain, or writes an
|
||||||
|
// error response and returns nil.
|
||||||
|
func (s *OrderServer) loadOrder(w http.ResponseWriter, r *http.Request) (*order.OrderGrain, error) {
|
||||||
|
raw := r.PathValue("id")
|
||||||
|
if raw == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing order id")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
id, ok := order.ParseOrderId(raw)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid order id")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
g, err := s.applier.Get(r.Context(), uint64(id))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "order not found")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if g.Status == order.StatusNew {
|
||||||
|
writeError(w, http.StatusNotFound, "order not found")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// orderGrainToResponse converts an OrderGrain to an OrderResponse.
|
||||||
|
func orderGrainToResponse(id string, g *order.OrderGrain) OrderResponse {
|
||||||
|
resp := OrderResponse{
|
||||||
|
OrderId: id,
|
||||||
|
Reference: g.OrderReference,
|
||||||
|
Status: string(g.Status),
|
||||||
|
Currency: g.Currency,
|
||||||
|
TotalAmount: g.TotalAmount,
|
||||||
|
TotalTax: g.TotalTax,
|
||||||
|
Lines: make([]OrderLineResp, 0, len(g.Lines)),
|
||||||
|
Payments: make([]OrderPaymentResp, 0, len(g.Payments)),
|
||||||
|
Fulfillments: make([]OrderFulfillmentResp, 0, len(g.Fulfillments)),
|
||||||
|
Returns: make([]OrderReturnResp, 0, len(g.Returns)),
|
||||||
|
Refunds: make([]OrderRefundResp, 0, len(g.Refunds)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, l := range g.Lines {
|
||||||
|
resp.Lines = append(resp.Lines, OrderLineResp{
|
||||||
|
Reference: l.Reference,
|
||||||
|
Sku: l.Sku,
|
||||||
|
Name: l.Name,
|
||||||
|
Quantity: l.Quantity,
|
||||||
|
UnitPrice: l.UnitPrice,
|
||||||
|
TaxRate: l.TaxRate,
|
||||||
|
TotalAmount: l.TotalAmount,
|
||||||
|
TotalTax: l.TotalTax,
|
||||||
|
Fulfilled: l.Fulfilled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range g.Payments {
|
||||||
|
if p == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp.Payments = append(resp.Payments, OrderPaymentResp{
|
||||||
|
Provider: p.Provider,
|
||||||
|
Authorized: p.Authorized,
|
||||||
|
Captured: p.Captured,
|
||||||
|
Refunded: p.Refunded,
|
||||||
|
AuthRef: p.AuthRef,
|
||||||
|
CaptureRef: p.CaptureRef,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range g.Fulfillments {
|
||||||
|
lines := make([]OrderLineEntry, 0, len(f.Lines))
|
||||||
|
for _, l := range f.Lines {
|
||||||
|
lines = append(lines, OrderLineEntry{Reference: l.Reference, Quantity: l.Quantity})
|
||||||
|
}
|
||||||
|
resp.Fulfillments = append(resp.Fulfillments, OrderFulfillmentResp{
|
||||||
|
Id: f.ID,
|
||||||
|
Carrier: f.Carrier,
|
||||||
|
TrackingNumber: f.TrackingNumber,
|
||||||
|
TrackingURI: f.TrackingURI,
|
||||||
|
Lines: lines,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range g.Returns {
|
||||||
|
lines := make([]OrderLineEntry, 0, len(r.Lines))
|
||||||
|
for _, l := range r.Lines {
|
||||||
|
lines = append(lines, OrderLineEntry{Reference: l.Reference, Quantity: l.Quantity})
|
||||||
|
}
|
||||||
|
resp.Returns = append(resp.Returns, OrderReturnResp{
|
||||||
|
Id: r.ID,
|
||||||
|
Reason: r.Reason,
|
||||||
|
Lines: lines,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range g.Refunds {
|
||||||
|
resp.Refunds = append(resp.Refunds, OrderRefundResp{
|
||||||
|
Provider: r.Provider,
|
||||||
|
Amount: r.Amount,
|
||||||
|
Reference: r.Reference,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.CapturedAmount = g.CapturedAmount
|
||||||
|
resp.RefundedAmount = g.RefundedAmount
|
||||||
|
resp.CustomerEmail = g.CustomerEmail
|
||||||
|
resp.CustomerName = g.CustomerName
|
||||||
|
resp.CartId = g.CartId
|
||||||
|
resp.PlacedAt = g.PlacedAt
|
||||||
|
resp.UpdatedAt = g.UpdatedAt
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
package ucp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||||
|
messages "git.k6n.net/mats/go-cart-actor/proto/order"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testOrderApplier implements OrderReadApplier with an in-memory map of grains.
|
||||||
|
type testOrderApplier struct {
|
||||||
|
grains map[uint64]*order.OrderGrain
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestOrderApplier() *testOrderApplier {
|
||||||
|
return &testOrderApplier{
|
||||||
|
grains: make(map[uint64]*order.OrderGrain),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *testOrderApplier) Get(_ context.Context, id uint64) (*order.OrderGrain, error) {
|
||||||
|
g, ok := a.grains[id]
|
||||||
|
if !ok {
|
||||||
|
// Auto-create so non-existent orders return StatusNew (expected 404).
|
||||||
|
g = order.NewOrderGrain(id, time.UnixMilli(1000000))
|
||||||
|
a.grains[id] = g
|
||||||
|
}
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *testOrderApplier) Apply(_ context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[order.OrderGrain], error) {
|
||||||
|
g, err := a.Get(nil, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results := make([]actor.ApplyResult, len(msgs))
|
||||||
|
for i, msg := range msgs {
|
||||||
|
results[i] = actor.ApplyResult{Type: string(msg.ProtoReflect().Descriptor().FullName())}
|
||||||
|
switch m := msg.(type) {
|
||||||
|
case *messages.CancelOrder:
|
||||||
|
if g.Status != order.StatusPending && g.Status != order.StatusAuthorized {
|
||||||
|
results[i].Error = errRejected("cannot cancel in status " + string(g.Status))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
g.Status = order.StatusCancelled
|
||||||
|
g.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||||
|
case *messages.CreateFulfillment:
|
||||||
|
if g.Status != order.StatusCaptured && g.Status != order.StatusPartiallyFulfilled {
|
||||||
|
results[i].Error = errRejected("cannot fulfill in status " + string(g.Status))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fulfillment := order.Fulfillment{
|
||||||
|
ID: m.Id,
|
||||||
|
Carrier: m.Carrier,
|
||||||
|
TrackingNumber: m.TrackingNumber,
|
||||||
|
TrackingURI: m.TrackingUri,
|
||||||
|
}
|
||||||
|
for _, fl := range m.Lines {
|
||||||
|
fulfillment.Lines = append(fulfillment.Lines, order.FulfillmentEntry{
|
||||||
|
Reference: fl.Reference,
|
||||||
|
Quantity: int(fl.Quantity),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
g.Fulfillments = append(g.Fulfillments, fulfillment)
|
||||||
|
g.Status = order.StatusFulfilled
|
||||||
|
case *messages.RequestReturn:
|
||||||
|
ret := order.Return{
|
||||||
|
ID: m.Id,
|
||||||
|
Reason: m.Reason,
|
||||||
|
}
|
||||||
|
for _, fl := range m.Lines {
|
||||||
|
ret.Lines = append(ret.Lines, order.FulfillmentEntry{
|
||||||
|
Reference: fl.Reference,
|
||||||
|
Quantity: int(fl.Quantity),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
g.Returns = append(g.Returns, ret)
|
||||||
|
case *messages.IssueRefund:
|
||||||
|
g.Refunds = append(g.Refunds, order.Refund{
|
||||||
|
Provider: m.Provider,
|
||||||
|
Amount: m.Amount,
|
||||||
|
Reference: m.Reference,
|
||||||
|
})
|
||||||
|
g.RefundedAmount += m.Amount
|
||||||
|
if g.RefundedAmount >= g.CapturedAmount {
|
||||||
|
g.Status = order.StatusRefunded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &actor.MutationResult[order.OrderGrain]{
|
||||||
|
Result: *g,
|
||||||
|
Mutations: results,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func errRejected(msg string) *actor.MutationError {
|
||||||
|
return &actor.MutationError{Message: msg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedOrder places an order in the test applier with a given status.
|
||||||
|
func seedOrder(a *testOrderApplier, status order.Status) string {
|
||||||
|
id, _ := order.NewOrderId()
|
||||||
|
g, _ := a.Get(nil, uint64(id))
|
||||||
|
g.Status = status
|
||||||
|
g.Currency = "SEK"
|
||||||
|
g.TotalAmount = 10000
|
||||||
|
g.TotalTax = 2000
|
||||||
|
g.Lines = []order.Line{
|
||||||
|
{Reference: "test-1", Sku: "test-1", Name: "Test Item", Quantity: 2, UnitPrice: 5000, TaxRate: 2500, TotalAmount: 10000, TotalTax: 2000},
|
||||||
|
}
|
||||||
|
if status == order.StatusCaptured {
|
||||||
|
g.CapturedAmount = 10000
|
||||||
|
}
|
||||||
|
return id.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderHandler_GetOrder(t *testing.T) {
|
||||||
|
a := newTestOrderApplier()
|
||||||
|
h := OrderHandler(a)
|
||||||
|
oid := seedOrder(a, order.StatusCaptured)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/"+oid, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp OrderResponse
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.OrderId != oid {
|
||||||
|
t.Fatalf("expected orderId %q, got %q", oid, resp.OrderId)
|
||||||
|
}
|
||||||
|
if resp.Status != "captured" {
|
||||||
|
t.Fatalf("expected status 'captured', got %q", resp.Status)
|
||||||
|
}
|
||||||
|
if len(resp.Lines) != 1 {
|
||||||
|
t.Fatalf("expected 1 line, got %d", len(resp.Lines))
|
||||||
|
}
|
||||||
|
if resp.Lines[0].Sku != "test-1" {
|
||||||
|
t.Fatalf("expected sku 'test-1', got %q", resp.Lines[0].Sku)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderHandler_GetOrderNotFound(t *testing.T) {
|
||||||
|
a := newTestOrderApplier()
|
||||||
|
h := OrderHandler(a)
|
||||||
|
|
||||||
|
// Non-existent order (not seeded) returns 404.
|
||||||
|
req := httptest.NewRequest("GET", "/nonexistent", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404 for non-existent order (StatusNew -> 404), got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderHandler_CancelOrder(t *testing.T) {
|
||||||
|
a := newTestOrderApplier()
|
||||||
|
h := OrderHandler(a)
|
||||||
|
oid := seedOrder(a, order.StatusAuthorized)
|
||||||
|
|
||||||
|
body := `{"reason": "customer request"}`
|
||||||
|
req := httptest.NewRequest("POST", "/"+oid+"/cancel", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp OrderResponse
|
||||||
|
json.NewDecoder(rec.Body).Decode(&resp)
|
||||||
|
if resp.Status != "cancelled" {
|
||||||
|
t.Fatalf("expected status 'cancelled', got %q", resp.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderHandler_CancelOrder_IllegalState(t *testing.T) {
|
||||||
|
a := newTestOrderApplier()
|
||||||
|
h := OrderHandler(a)
|
||||||
|
oid := seedOrder(a, order.StatusFulfilled) // can't cancel from fulfilled
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/"+oid+"/cancel", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("expected 422 for illegal transition, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderHandler_CreateFulfillment(t *testing.T) {
|
||||||
|
a := newTestOrderApplier()
|
||||||
|
h := OrderHandler(a)
|
||||||
|
oid := seedOrder(a, order.StatusCaptured)
|
||||||
|
|
||||||
|
body := `{"carrier":"PostNord","trackingNumber":"ABC123","lines":[{"reference":"test-1","quantity":2}]}`
|
||||||
|
req := httptest.NewRequest("POST", "/"+oid+"/fulfillments", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp OrderResponse
|
||||||
|
json.NewDecoder(rec.Body).Decode(&resp)
|
||||||
|
if len(resp.Fulfillments) != 1 {
|
||||||
|
t.Fatalf("expected 1 fulfillment, got %d", len(resp.Fulfillments))
|
||||||
|
}
|
||||||
|
if resp.Fulfillments[0].Carrier != "PostNord" {
|
||||||
|
t.Fatalf("expected carrier 'PostNord', got %q", resp.Fulfillments[0].Carrier)
|
||||||
|
}
|
||||||
|
if resp.Fulfillments[0].TrackingNumber != "ABC123" {
|
||||||
|
t.Fatalf("expected tracking 'ABC123', got %q", resp.Fulfillments[0].TrackingNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderHandler_RequestReturn(t *testing.T) {
|
||||||
|
a := newTestOrderApplier()
|
||||||
|
h := OrderHandler(a)
|
||||||
|
oid := seedOrder(a, order.StatusFulfilled)
|
||||||
|
|
||||||
|
body := `{"reason":"defective","lines":[{"reference":"test-1","quantity":1}]}`
|
||||||
|
req := httptest.NewRequest("POST", "/"+oid+"/returns", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp OrderResponse
|
||||||
|
json.NewDecoder(rec.Body).Decode(&resp)
|
||||||
|
if len(resp.Returns) != 1 {
|
||||||
|
t.Fatalf("expected 1 return, got %d", len(resp.Returns))
|
||||||
|
}
|
||||||
|
if resp.Returns[0].Reason != "defective" {
|
||||||
|
t.Fatalf("expected reason 'defective', got %q", resp.Returns[0].Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderHandler_IssueRefund(t *testing.T) {
|
||||||
|
a := newTestOrderApplier()
|
||||||
|
h := OrderHandler(a)
|
||||||
|
oid := seedOrder(a, order.StatusCaptured)
|
||||||
|
|
||||||
|
body := `{"amount":3000}`
|
||||||
|
req := httptest.NewRequest("POST", "/"+oid+"/refunds", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp OrderResponse
|
||||||
|
json.NewDecoder(rec.Body).Decode(&resp)
|
||||||
|
if len(resp.Refunds) != 1 {
|
||||||
|
t.Fatalf("expected 1 refund, got %d", len(resp.Refunds))
|
||||||
|
}
|
||||||
|
if resp.Refunds[0].Amount != 3000 {
|
||||||
|
t.Fatalf("expected amount 3000, got %d", resp.Refunds[0].Amount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderHandler_SignatureIntegration(t *testing.T) {
|
||||||
|
cfg := mustLoadTestKey(t)
|
||||||
|
a := newTestOrderApplier()
|
||||||
|
oid := seedOrder(a, order.StatusCaptured)
|
||||||
|
|
||||||
|
h := WithSigning(OrderHandler(a), cfg)
|
||||||
|
req := httptest.NewRequest("GET", "/"+oid, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if rec.Header().Get("Signature-Input") == "" {
|
||||||
|
t.Fatal("expected Signature-Input with signing enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package ucp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SigningConfig — ECDSA P-256 key for RFC 9421 HTTP Message Signatures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// SigningConfig holds the ECDSA P-256 private key and key ID for signing UCP
|
||||||
|
// HTTP responses with RFC 9421 HTTP Message Signatures. The corresponding
|
||||||
|
// public key is published in the UCP profile (signing_keys) and served at
|
||||||
|
// /.well-known/ucp so any UCP platform can verify response authenticity.
|
||||||
|
type SigningConfig struct {
|
||||||
|
PrivateKey *ecdsa.PrivateKey
|
||||||
|
KeyID string // matches the kid in the UCP profile signing_keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSigningConfigFromPEM parses a PEM-encoded ECDSA P-256 private key and
|
||||||
|
// returns a SigningConfig ready for use.
|
||||||
|
//
|
||||||
|
// data — PEM block containing an EC PRIVATE KEY (PKCS#8 or SEC1)
|
||||||
|
// keyID — the kid matched by the UCP profile signing_keys entry
|
||||||
|
func NewSigningConfigFromPEM(pemData []byte, keyID string) (*SigningConfig, error) {
|
||||||
|
block, _ := pem.Decode(pemData)
|
||||||
|
if block == nil {
|
||||||
|
return nil, fmt.Errorf("ucp signing: no PEM block found")
|
||||||
|
}
|
||||||
|
|
||||||
|
var priv any
|
||||||
|
var err error
|
||||||
|
switch block.Type {
|
||||||
|
case "EC PRIVATE KEY":
|
||||||
|
// SEC1 format (openssl ecparam -genkey -name prime256v1)
|
||||||
|
priv, err = x509.ParseECPrivateKey(block.Bytes)
|
||||||
|
case "PRIVATE KEY":
|
||||||
|
// PKCS#8 format
|
||||||
|
priv, err = x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("ucp signing: unsupported PEM type %q", block.Type)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ucp signing: parse key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ecKey, ok := priv.(*ecdsa.PrivateKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("ucp signing: key is not ECDSA")
|
||||||
|
}
|
||||||
|
if ecKey.Curve != elliptic.P256() {
|
||||||
|
return nil, fmt.Errorf("ucp signing: key is not P-256 (got %s)", ecKey.Curve.Params().Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SigningConfig{PrivateKey: ecKey, KeyID: keyID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WithSigning — HTTP middleware wrapper for RFC 9421 response signing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// WithSigning wraps h with an HTTP middleware that adds RFC 9421 HTTP Message
|
||||||
|
// Signatures to every response. The Signature-Input and Signature headers are
|
||||||
|
// computed over @status, content-type, and x-ucp-timestamp, signed with the
|
||||||
|
// configured ECDSA P-256 key.
|
||||||
|
//
|
||||||
|
// Mount it on any existing UCP handler:
|
||||||
|
//
|
||||||
|
// mux.Handle("/ucp/v1/carts", ucp.WithSigning(ucp.CartHandler(pool), cfg))
|
||||||
|
func WithSigning(h http.Handler, cfg *SigningConfig) http.Handler {
|
||||||
|
if cfg == nil || cfg.PrivateKey == nil {
|
||||||
|
return h // pass through without signing
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sw := &signedWriter{
|
||||||
|
ResponseWriter: w,
|
||||||
|
cfg: cfg,
|
||||||
|
}
|
||||||
|
h.ServeHTTP(sw, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// signedWriter — response interceptor that adds signature headers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type signedWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
cfg *SigningConfig
|
||||||
|
statusCode int
|
||||||
|
wroteHeader bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *signedWriter) WriteHeader(statusCode int) {
|
||||||
|
if w.wroteHeader {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.wroteHeader = true
|
||||||
|
w.statusCode = statusCode
|
||||||
|
|
||||||
|
created := time.Now().Unix()
|
||||||
|
w.ResponseWriter.Header().Set("x-ucp-timestamp", strconv.FormatInt(created, 10))
|
||||||
|
|
||||||
|
// Add signature headers before flushing.
|
||||||
|
w.addSignatureHeaders(created)
|
||||||
|
|
||||||
|
w.ResponseWriter.WriteHeader(statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *signedWriter) Write(p []byte) (int, error) {
|
||||||
|
if !w.wroteHeader {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
return w.ResponseWriter.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addSignatureHeaders computes and writes the Signature-Input and Signature
|
||||||
|
// headers per RFC 9421 §3.2 / §3.3.
|
||||||
|
func (w *signedWriter) addSignatureHeaders(created int64) {
|
||||||
|
contentType := w.ResponseWriter.Header().Get("Content-Type")
|
||||||
|
sigTimestamp := strconv.FormatInt(created, 10)
|
||||||
|
|
||||||
|
// Covered components (in order). RFC 9421 §3.2: derived component names
|
||||||
|
// (@status) are bare identifiers; HTTP header field names are sf-strings
|
||||||
|
// and MUST be quoted.
|
||||||
|
components := []string{
|
||||||
|
"@status",
|
||||||
|
`"content-type"`,
|
||||||
|
`"x-ucp-timestamp"`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the signature base string (RFC 9421 §2.2).
|
||||||
|
var base strings.Builder
|
||||||
|
base.WriteString(fmt.Sprintf(`"@status": %d`, w.statusCode))
|
||||||
|
base.WriteByte('\n')
|
||||||
|
base.WriteString(fmt.Sprintf(`"content-type": %s`, contentType))
|
||||||
|
base.WriteByte('\n')
|
||||||
|
base.WriteString(fmt.Sprintf(`"x-ucp-timestamp": %s`, sigTimestamp))
|
||||||
|
base.WriteByte('\n')
|
||||||
|
|
||||||
|
// Append the signature-params pseudo-line (RFC 9421 §2.2).
|
||||||
|
compList := strings.Join(components, " ")
|
||||||
|
base.WriteString(fmt.Sprintf(`"@signature-params": (%s);created=%d;keyid=%q;alg="ecdsa-p256"`,
|
||||||
|
compList, created, w.cfg.KeyID))
|
||||||
|
|
||||||
|
// Sign the SHA-256 digest.
|
||||||
|
digest := sha256.Sum256([]byte(base.String()))
|
||||||
|
r, s, err := ecdsa.Sign(rand.Reader, w.cfg.PrivateKey, digest[:])
|
||||||
|
if err != nil {
|
||||||
|
// Fail open — response is sent without signature headers.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ECDSA P-256 signature raw bytes: r||s concatenation, each 32 bytes.
|
||||||
|
rBytes := r.Bytes()
|
||||||
|
sBytes := s.Bytes()
|
||||||
|
rawSig := make([]byte, 64)
|
||||||
|
copy(rawSig[32-len(rBytes):32], rBytes)
|
||||||
|
copy(rawSig[64-len(sBytes):64], sBytes)
|
||||||
|
|
||||||
|
sigB64 := base64.RawURLEncoding.EncodeToString(rawSig)
|
||||||
|
|
||||||
|
// Signature-Input: sig1=(components);created=N;keyid="...";alg="ecdsa-p256"
|
||||||
|
sigInput := fmt.Sprintf(`sig1=(%s);created=%d;keyid=%q;alg="ecdsa-p256"`,
|
||||||
|
compList, created, w.cfg.KeyID)
|
||||||
|
|
||||||
|
// Signature: sig1=:base64url:
|
||||||
|
sigValue := fmt.Sprintf("sig1=:%s:", sigB64)
|
||||||
|
|
||||||
|
w.ResponseWriter.Header().Set("Signature-Input", sigInput)
|
||||||
|
w.ResponseWriter.Header().Set("Signature", sigValue)
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package ucp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSigningConfig_FromPEM(t *testing.T) {
|
||||||
|
// PEM from the generated key pair (openssl ecparam -genkey -name prime256v1)
|
||||||
|
pemData := `-----BEGIN EC PRIVATE KEY-----
|
||||||
|
MHcCAQEEIDTssEctnrEqym80fx2jEVolTW+tWrHyo8fmbi8hMFWmoAoGCCqGSM49
|
||||||
|
AwEHoUQDQgAEpnPksDSSMcNgUYT4++Z6XuS0V2p6TNMSjWBsgXOu7mo5Esoux+Cm
|
||||||
|
y+C84ty5VUAdEpoDyNfWMeaAlHKGo+FjiQ==
|
||||||
|
-----END EC PRIVATE KEY-----`
|
||||||
|
|
||||||
|
cfg, err := NewSigningConfigFromPEM([]byte(pemData), "k6n-ecdsa-2026")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSigningConfigFromPEM failed: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.KeyID != "k6n-ecdsa-2026" {
|
||||||
|
t.Fatalf("expected key ID 'k6n-ecdsa-2026', got %q", cfg.KeyID)
|
||||||
|
}
|
||||||
|
if cfg.PrivateKey == nil {
|
||||||
|
t.Fatal("expected non-nil private key")
|
||||||
|
}
|
||||||
|
if cfg.PrivateKey.Curve.Params().Name != "P-256" {
|
||||||
|
t.Fatalf("expected P-256 curve, got %s", cfg.PrivateKey.Curve.Params().Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSigningConfig_InvalidPEM(t *testing.T) {
|
||||||
|
_, err := NewSigningConfigFromPEM([]byte("not a pem"), "test")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid PEM")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithSigning_AddsHeaders(t *testing.T) {
|
||||||
|
// Load the test key.
|
||||||
|
cfg := mustLoadTestKey(t)
|
||||||
|
|
||||||
|
// A simple handler that returns a JSON response.
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
})
|
||||||
|
|
||||||
|
wrapped := WithSigning(handler, cfg)
|
||||||
|
req := httptest.NewRequest("GET", "/test", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
wrapped.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
// Check the response body.
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that signature headers are present.
|
||||||
|
sigInput := rec.Header().Get("Signature-Input")
|
||||||
|
sig := rec.Header().Get("Signature")
|
||||||
|
ts := rec.Header().Get("x-ucp-timestamp")
|
||||||
|
|
||||||
|
if sigInput == "" {
|
||||||
|
t.Fatal("expected Signature-Input header")
|
||||||
|
}
|
||||||
|
if sig == "" {
|
||||||
|
t.Fatal("expected Signature header")
|
||||||
|
}
|
||||||
|
if ts == "" {
|
||||||
|
t.Fatal("expected x-ucp-timestamp header")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the signature input format.
|
||||||
|
if !strings.HasPrefix(sigInput, `sig1=(@status "content-type" "x-ucp-timestamp");`) {
|
||||||
|
t.Fatalf("unexpected Signature-Input format: %q", sigInput)
|
||||||
|
}
|
||||||
|
if !strings.Contains(sigInput, `keyid="k6n-ecdsa-2026"`) {
|
||||||
|
t.Fatalf("Signature-Input missing keyid: %q", sigInput)
|
||||||
|
}
|
||||||
|
if !strings.Contains(sigInput, `alg="ecdsa-p256"`) {
|
||||||
|
t.Fatalf("Signature-Input missing alg: %q", sigInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the signature format.
|
||||||
|
if !strings.HasPrefix(sig, "sig1=:") || !strings.HasSuffix(sig, ":") {
|
||||||
|
t.Fatalf("unexpected Signature format: %q", sig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithSigning_NilConfig(t *testing.T) {
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
|
||||||
|
wrapped := WithSigning(handler, nil)
|
||||||
|
req := httptest.NewRequest("GET", "/test", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
wrapped.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if rec.Header().Get("Signature-Input") != "" {
|
||||||
|
t.Fatal("expected no Signature-Input when signing is nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithSigning_DifferentStatuses(t *testing.T) {
|
||||||
|
cfg := mustLoadTestKey(t)
|
||||||
|
|
||||||
|
for _, status := range []int{200, 201, 400, 404, 500} {
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(status)
|
||||||
|
w.Write([]byte(`{}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
wrapped := WithSigning(handler, cfg)
|
||||||
|
req := httptest.NewRequest("GET", "/test", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
wrapped.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != status {
|
||||||
|
t.Fatalf("expected status %d, got %d", status, rec.Code)
|
||||||
|
}
|
||||||
|
if rec.Header().Get("Signature-Input") == "" {
|
||||||
|
t.Fatalf("expected Signature-Input for status %d", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithSigning_WithUCPCartHandler(t *testing.T) {
|
||||||
|
cfg := mustLoadTestKey(t)
|
||||||
|
applier := newTestApplier()
|
||||||
|
handler := CartHandler(applier)
|
||||||
|
wrapped := WithSigning(handler, cfg)
|
||||||
|
|
||||||
|
// Create a cart through the wrapped handler.
|
||||||
|
req := httptest.NewRequest("POST", "/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
wrapped.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("expected 201, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signed.
|
||||||
|
if rec.Header().Get("Signature-Input") == "" {
|
||||||
|
t.Fatal("expected Signature-Input on UCP cart response")
|
||||||
|
}
|
||||||
|
if rec.Header().Get("x-ucp-timestamp") == "" {
|
||||||
|
t.Fatal("expected x-ucp-timestamp on UCP cart response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustLoadTestKey loads the test PEM for signing tests.
|
||||||
|
func mustLoadTestKey(t testing.TB) *SigningConfig {
|
||||||
|
t.Helper()
|
||||||
|
pemData := `-----BEGIN EC PRIVATE KEY-----
|
||||||
|
MHcCAQEEIDTssEctnrEqym80fx2jEVolTW+tWrHyo8fmbi8hMFWmoAoGCCqGSM49
|
||||||
|
AwEHoUQDQgAEpnPksDSSMcNgUYT4++Z6XuS0V2p6TNMSjWBsgXOu7mo5Esoux+Cm
|
||||||
|
y+C84ty5VUAdEpoDyNfWMeaAlHKGo+FjiQ==
|
||||||
|
-----END EC PRIVATE KEY-----`
|
||||||
|
cfg, err := NewSigningConfigFromPEM([]byte(pemData), "k6n-ecdsa-2026")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mustLoadTestKey: %v", err)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
@@ -0,0 +1,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})
|
||||||
|
}
|
||||||
@@ -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}}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user