mcp and ucp
This commit is contained in:
@@ -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})
|
||||
}
|
||||
Reference in New Issue
Block a user