add profile actors
This commit is contained in:
+20
-3
@@ -3,10 +3,12 @@ package ucp
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||
"google.golang.org/protobuf/proto"
|
||||
@@ -14,8 +16,9 @@ import (
|
||||
|
||||
// CartServer is the UCP REST adapter over the cart grain pool.
|
||||
type CartServer struct {
|
||||
applier CartApplier
|
||||
newCartId func() (cart.CartId, error)
|
||||
applier CartApplier
|
||||
profileApplier ProfileApplier // optional; when set, links carts to customer profiles
|
||||
newCartId func() (cart.CartId, error)
|
||||
}
|
||||
|
||||
// NewCartServer builds a UCP REST adapter over a cart grain pool.
|
||||
@@ -26,6 +29,11 @@ func NewCartServer(applier CartApplier) *CartServer {
|
||||
}
|
||||
}
|
||||
|
||||
// SetProfileApplier enables identity linking on this server.
|
||||
func (s *CartServer) SetProfileApplier(pa ProfileApplier) {
|
||||
s.profileApplier = pa
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UCP Cart endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -113,12 +121,21 @@ func (s *CartServer) handleUpdateCart(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Set user ID.
|
||||
// Set user ID and auto-link cart to customer profile.
|
||||
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
|
||||
}
|
||||
|
||||
// Auto-link this cart to the customer's profile if identity linking is enabled.
|
||||
if s.profileApplier != nil {
|
||||
if pid, ok := profile.ParseProfileId(*req.UserId); ok {
|
||||
if err := linkCartToProfile(s.profileApplier, r.Context(), uint64(pid), id); err != nil {
|
||||
log.Printf("identity: failed to link cart %d to profile %d: %v", id, uint64(pid), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
g, err := s.applier.Get(r.Context(), id)
|
||||
|
||||
@@ -6,12 +6,14 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
profilePkg "git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||
checkoutMessages "git.k6n.net/mats/go-cart-actor/proto/checkout"
|
||||
"google.golang.org/protobuf/types/known/anypb"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
@@ -19,8 +21,9 @@ import (
|
||||
|
||||
// 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
|
||||
applier CheckoutApplier
|
||||
profileApplier ProfileApplier // optional; when set links checkouts/orders to profiles
|
||||
orderSvc OrderApplier // optional; when set creates real orders on complete
|
||||
}
|
||||
|
||||
// NewCheckoutServer builds a UCP REST adapter over a checkout grain pool.
|
||||
@@ -32,6 +35,11 @@ func NewCheckoutServer(applier CheckoutApplier, orderSvc ...OrderApplier) *Check
|
||||
return s
|
||||
}
|
||||
|
||||
// SetProfileApplier enables identity linking on this server.
|
||||
func (s *CheckoutServer) SetProfileApplier(pa ProfileApplier) {
|
||||
s.profileApplier = pa
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UCP Checkout endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -180,6 +188,15 @@ func (s *CheckoutServer) handleCreateCheckout(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
|
||||
// Link checkout to customer profile if identity linking is enabled.
|
||||
if s.profileApplier != nil && req.CustomerId != "" {
|
||||
if pid, ok := profilePkg.ParseProfileId(req.CustomerId); ok {
|
||||
if err := linkCheckoutToProfile(s.profileApplier, r.Context(), uint64(pid), uint64(checkoutId), uint64(cartId)); err != nil {
|
||||
log.Printf("identity: failed to link checkout %d to profile %s: %v", uint64(checkoutId), req.CustomerId, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, checkoutGrainToResponse(checkoutId.String(), g))
|
||||
}
|
||||
|
||||
@@ -336,6 +353,15 @@ func (s *CheckoutServer) handleCompleteCheckout(w http.ResponseWriter, r *http.R
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-link order to customer profile if identity linking is enabled.
|
||||
if s.profileApplier != nil && orderID != "" && req.CustomerId != "" {
|
||||
if pid, ok := profilePkg.ParseProfileId(req.CustomerId); ok {
|
||||
if err := linkOrderToProfile(s.profileApplier, r.Context(), uint64(pid), orderID, uint64(g.CartId), "placed"); err != nil {
|
||||
log.Printf("identity: failed to link order %s to profile %s: %v", orderID, req.CustomerId, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, checkoutGrainToResponse(r.PathValue("id"), g))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
package ucp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/profile"
|
||||
)
|
||||
|
||||
// CustomerServer is the UCP REST adapter over the profile grain pool.
|
||||
type CustomerServer struct {
|
||||
applier ProfileApplier
|
||||
}
|
||||
|
||||
// NewCustomerServer builds a UCP REST adapter over a profile grain pool.
|
||||
func NewCustomerServer(applier ProfileApplier) *CustomerServer {
|
||||
return &CustomerServer{applier: applier}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UCP Customer endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleGetCustomer handles GET /customers/{id}.
|
||||
func (s *CustomerServer) handleGetCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseCustomerID(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "invalid customer id")
|
||||
return
|
||||
}
|
||||
|
||||
g, err := s.applier.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "customer not found")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
|
||||
}
|
||||
|
||||
// handleUpdateCustomer handles PUT /customers/{id}.
|
||||
func (s *CustomerServer) handleUpdateCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseCustomerID(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "invalid customer id")
|
||||
return
|
||||
}
|
||||
|
||||
var req CustomerUpdateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
msg := &messages.SetProfile{
|
||||
Name: req.Name,
|
||||
Email: req.Email,
|
||||
Phone: req.Phone,
|
||||
Language: req.Language,
|
||||
Currency: req.Currency,
|
||||
AvatarUrl: req.AvatarUrl,
|
||||
}
|
||||
|
||||
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to update customer: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
g, err := s.applier.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to read customer: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
|
||||
}
|
||||
|
||||
// handleAddAddress handles POST /customers/{id}/addresses.
|
||||
func (s *CustomerServer) handleAddAddress(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseCustomerID(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "invalid customer id")
|
||||
return
|
||||
}
|
||||
|
||||
var req AddAddressRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.AddressLine1 == "" || req.City == "" || req.Zip == "" || req.Country == "" {
|
||||
writeError(w, http.StatusBadRequest, "addressLine1, city, zip, and country are required")
|
||||
return
|
||||
}
|
||||
|
||||
addr := &messages.Address{
|
||||
Label: req.Label,
|
||||
FullName: req.FullName,
|
||||
AddressLine1: req.AddressLine1,
|
||||
City: req.City,
|
||||
State: req.State,
|
||||
Zip: req.Zip,
|
||||
Country: req.Country,
|
||||
IsDefaultShipping: req.IsDefaultShipping,
|
||||
IsDefaultBilling: req.IsDefaultBilling,
|
||||
}
|
||||
if req.AddressLine2 != "" {
|
||||
addr.AddressLine2 = &req.AddressLine2
|
||||
}
|
||||
if req.Phone != "" {
|
||||
addr.Phone = &req.Phone
|
||||
}
|
||||
msg := &messages.AddAddress{Address: addr}
|
||||
|
||||
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to add address: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
g, err := s.applier.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to read customer: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, customerGrainToResponse(r.PathValue("id"), g))
|
||||
}
|
||||
|
||||
// handleUpdateAddress handles PUT /customers/{id}/addresses/{addressId}.
|
||||
func (s *CustomerServer) handleUpdateAddress(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseCustomerID(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "invalid customer id")
|
||||
return
|
||||
}
|
||||
|
||||
addressID, ok := parseAddressID(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "invalid address id")
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateAddressRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
msg := &messages.UpdateAddress{
|
||||
Id: addressID,
|
||||
Label: req.Label,
|
||||
FullName: req.FullName,
|
||||
AddressLine1: req.AddressLine1,
|
||||
AddressLine2: req.AddressLine2,
|
||||
City: req.City,
|
||||
State: req.State,
|
||||
Zip: req.Zip,
|
||||
Country: req.Country,
|
||||
Phone: req.Phone,
|
||||
IsDefaultShipping: req.IsDefaultShipping,
|
||||
IsDefaultBilling: req.IsDefaultBilling,
|
||||
}
|
||||
|
||||
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to update address: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
g, err := s.applier.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to read customer: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
|
||||
}
|
||||
|
||||
// handleRemoveAddress handles DELETE /customers/{id}/addresses/{addressId}.
|
||||
func (s *CustomerServer) handleRemoveAddress(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseCustomerID(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "invalid customer id")
|
||||
return
|
||||
}
|
||||
|
||||
addressID, ok := parseAddressID(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "invalid address id")
|
||||
return
|
||||
}
|
||||
|
||||
msg := &messages.RemoveAddress{Id: addressID}
|
||||
|
||||
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
|
||||
// Check if the error is a "not found" error.
|
||||
errStr := err.Error()
|
||||
if containsNotFound(errStr) {
|
||||
writeError(w, http.StatusNotFound, fmt.Sprintf("address %d not found", addressID))
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to remove address: "+errStr)
|
||||
return
|
||||
}
|
||||
|
||||
g, err := s.applier.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to read customer: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identity linking helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// linkCartToProfile links a cart to the given profile by applying a LinkCart mutation.
|
||||
func linkCartToProfile(applier ProfileApplier, ctx context.Context, profileID uint64, cartID uint64) error {
|
||||
if applier == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := applier.Apply(ctx, profileID, &messages.LinkCart{
|
||||
CartId: cartID,
|
||||
Label: "current cart",
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// linkCheckoutToProfile links a checkout to the given profile by applying a LinkCheckout mutation.
|
||||
func linkCheckoutToProfile(applier ProfileApplier, ctx context.Context, profileID uint64, checkoutID uint64, cartID uint64) error {
|
||||
if applier == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := applier.Apply(ctx, profileID, &messages.LinkCheckout{
|
||||
CheckoutId: checkoutID,
|
||||
CartId: cartID,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// linkOrderToProfile links an order to the given profile by applying a LinkOrder mutation.
|
||||
func linkOrderToProfile(applier ProfileApplier, ctx context.Context, profileID uint64, orderReference string, cartID uint64, status string) error {
|
||||
if applier == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := applier.Apply(ctx, profileID, &messages.LinkOrder{
|
||||
OrderReference: orderReference,
|
||||
CartId: cartID,
|
||||
Status: status,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// parseCustomerID parses the base62 customer id from the path.
|
||||
func parseCustomerID(r *http.Request) (uint64, bool) {
|
||||
raw := r.PathValue("id")
|
||||
if raw == "" {
|
||||
return 0, false
|
||||
}
|
||||
cid, ok := profile.ParseProfileId(raw)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return uint64(cid), true
|
||||
}
|
||||
|
||||
// parseAddressID parses the numeric address id from the path.
|
||||
func parseAddressID(r *http.Request) (uint32, bool) {
|
||||
raw := r.PathValue("addressId")
|
||||
if raw == "" {
|
||||
return 0, false
|
||||
}
|
||||
// Address IDs are simple numeric strings.
|
||||
var id uint32
|
||||
for _, c := range raw {
|
||||
if c < '0' || c > '9' {
|
||||
return 0, false
|
||||
}
|
||||
id = id*10 + uint32(c-'0')
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// containsNotFound reports whether errStr indicates a "not found" condition.
|
||||
func containsNotFound(errStr string) bool {
|
||||
// Check for common not-found patterns in the error message.
|
||||
for _, pattern := range []string{"not found", "not_found", "notfound"} {
|
||||
if len(errStr) >= len(pattern) {
|
||||
for i := 0; i <= len(errStr)-len(pattern); i++ {
|
||||
match := true
|
||||
for j := 0; j < len(pattern); j++ {
|
||||
c1 := errStr[i+j]
|
||||
c2 := pattern[j]
|
||||
if c1 >= 'A' && c1 <= 'Z' {
|
||||
c1 += 32
|
||||
}
|
||||
if c1 != c2 {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// customerGrainToResponse converts a ProfileGrain to a CustomerResponse.
|
||||
func customerGrainToResponse(id string, g *profile.ProfileGrain) CustomerResponse {
|
||||
resp := CustomerResponse{
|
||||
Id: id,
|
||||
Name: g.Name,
|
||||
Email: g.Email,
|
||||
Phone: g.Phone,
|
||||
Language: g.Language,
|
||||
Currency: g.Currency,
|
||||
AvatarUrl: g.AvatarUrl,
|
||||
Addresses: make([]AddressResponse, 0, len(g.Addresses)),
|
||||
}
|
||||
|
||||
for _, a := range g.Addresses {
|
||||
resp.Addresses = append(resp.Addresses, AddressResponse{
|
||||
Id: a.Id,
|
||||
Label: a.Label,
|
||||
FullName: a.FullName,
|
||||
AddressLine1: a.AddressLine1,
|
||||
AddressLine2: a.AddressLine2,
|
||||
City: a.City,
|
||||
State: a.State,
|
||||
Zip: a.Zip,
|
||||
Country: a.Country,
|
||||
Phone: a.Phone,
|
||||
IsDefaultShipping: a.IsDefaultShipping,
|
||||
IsDefaultBilling: a.IsDefaultBilling,
|
||||
})
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
package ucp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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/cart"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/profile"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// testProfileApplier implements ProfileApplier with in-memory grains.
|
||||
type testProfileApplier struct {
|
||||
grains map[uint64]*profile.ProfileGrain
|
||||
}
|
||||
|
||||
func newTestProfileApplier() *testProfileApplier {
|
||||
return &testProfileApplier{
|
||||
grains: make(map[uint64]*profile.ProfileGrain),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *testProfileApplier) Get(_ context.Context, id uint64) (*profile.ProfileGrain, error) {
|
||||
g, ok := a.grains[id]
|
||||
if !ok {
|
||||
g = profile.NewProfileGrain(id, time.UnixMilli(1000000))
|
||||
a.grains[id] = g
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (a *testProfileApplier) Apply(ctx context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error) {
|
||||
g, err := a.Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, msg := range msgs {
|
||||
switch m := msg.(type) {
|
||||
case *messages.SetProfile:
|
||||
if m.Name != nil {
|
||||
g.Name = *m.Name
|
||||
}
|
||||
if m.Email != nil {
|
||||
g.Email = *m.Email
|
||||
}
|
||||
if m.Phone != nil {
|
||||
g.Phone = *m.Phone
|
||||
}
|
||||
if m.Language != nil {
|
||||
g.Language = *m.Language
|
||||
}
|
||||
if m.Currency != nil {
|
||||
g.Currency = *m.Currency
|
||||
}
|
||||
if m.AvatarUrl != nil {
|
||||
g.AvatarUrl = *m.AvatarUrl
|
||||
}
|
||||
case *messages.AddAddress:
|
||||
if m.Address == nil {
|
||||
continue
|
||||
}
|
||||
g.Addresses = append(g.Addresses, profile.StoredAddress{
|
||||
Id: g.NextAddrId(),
|
||||
Label: m.Address.Label,
|
||||
FullName: m.Address.FullName,
|
||||
AddressLine1: m.Address.AddressLine1,
|
||||
AddressLine2: m.Address.GetAddressLine2(),
|
||||
City: m.Address.City,
|
||||
State: m.Address.State,
|
||||
Zip: m.Address.Zip,
|
||||
Country: m.Address.Country,
|
||||
Phone: m.Address.GetPhone(),
|
||||
IsDefaultShipping: m.Address.IsDefaultShipping,
|
||||
IsDefaultBilling: m.Address.IsDefaultBilling,
|
||||
})
|
||||
case *messages.UpdateAddress:
|
||||
for i := range g.Addresses {
|
||||
if g.Addresses[i].Id == m.Id {
|
||||
if m.Label != nil {
|
||||
g.Addresses[i].Label = *m.Label
|
||||
}
|
||||
if m.AddressLine1 != nil {
|
||||
g.Addresses[i].AddressLine1 = *m.AddressLine1
|
||||
}
|
||||
}
|
||||
}
|
||||
case *messages.RemoveAddress:
|
||||
for i := range g.Addresses {
|
||||
if g.Addresses[i].Id == m.Id {
|
||||
g.Addresses = append(g.Addresses[:i], g.Addresses[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
case *messages.LinkCart:
|
||||
g.Carts = append(g.Carts, profile.LinkedCart{CartId: m.CartId, Label: m.Label})
|
||||
case *messages.LinkCheckout:
|
||||
g.Checkouts = append(g.Checkouts, profile.LinkedCheckout{CheckoutId: m.CheckoutId, CartId: m.CartId})
|
||||
case *messages.LinkOrder:
|
||||
g.Orders = append(g.Orders, profile.LinkedOrder{OrderReference: m.OrderReference, CartId: m.CartId, Status: m.Status})
|
||||
}
|
||||
}
|
||||
return &actor.MutationResult[profile.ProfileGrain]{
|
||||
Result: *g,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// testCartApplier is a minimal CartApplier mock for combined handler tests.
|
||||
type testCartApplier struct{}
|
||||
|
||||
func (a *testCartApplier) Get(ctx context.Context, id uint64) (*cart.CartGrain, error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (a *testCartApplier) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[cart.CartGrain], error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
// Helper to get a test profile ID as a base62 string.
|
||||
func testProfileID(t *testing.T, applier *testProfileApplier) string {
|
||||
t.Helper()
|
||||
pid, err := profile.NewProfileId()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate profile id: %v", err)
|
||||
}
|
||||
// Seed the grain.
|
||||
applier.Get(context.Background(), uint64(pid))
|
||||
return pid.String()
|
||||
}
|
||||
|
||||
func TestGetCustomer(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
id := testProfileID(t, applier)
|
||||
handler := CustomerHandler(applier)
|
||||
|
||||
// Set up some profile data
|
||||
pid, _ := profile.ParseProfileId(id)
|
||||
applier.Apply(context.Background(), uint64(pid), &messages.SetProfile{
|
||||
Name: proto.String("Alice"),
|
||||
Email: proto.String("alice@example.com"),
|
||||
})
|
||||
|
||||
// GET /customers/{id}
|
||||
req := httptest.NewRequest("GET", fmt.Sprintf("/%s", id), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp CustomerResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp.Id != id {
|
||||
t.Fatalf("expected id %q, got %q", id, resp.Id)
|
||||
}
|
||||
if resp.Name != "Alice" {
|
||||
t.Fatalf("expected Name 'Alice', got %q", resp.Name)
|
||||
}
|
||||
if resp.Email != "alice@example.com" {
|
||||
t.Fatalf("expected Email 'alice@example.com', got %q", resp.Email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCustomerNotFound(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
handler := CustomerHandler(applier)
|
||||
|
||||
// "nonexistent" is actually valid base62, so use an ID with characters outside the alphabet.
|
||||
req := httptest.NewRequest("GET", "/!invalid!", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 Bad Request for invalid id, got %d. Body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCustomer(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
id := testProfileID(t, applier)
|
||||
handler := CustomerHandler(applier)
|
||||
|
||||
body := `{"name": "Bob", "email": "bob@example.com", "phone": "+46701234567", "language": "sv", "currency": "SEK"}`
|
||||
req := httptest.NewRequest("PUT", fmt.Sprintf("/%s", id), strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp CustomerResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp.Name != "Bob" {
|
||||
t.Fatalf("expected Name 'Bob', got %q", resp.Name)
|
||||
}
|
||||
if resp.Email != "bob@example.com" {
|
||||
t.Fatalf("expected Email 'bob@example.com', got %q", resp.Email)
|
||||
}
|
||||
if resp.Phone != "+46701234567" {
|
||||
t.Fatalf("expected Phone '+46701234567', got %q", resp.Phone)
|
||||
}
|
||||
if resp.Language != "sv" {
|
||||
t.Fatalf("expected Language 'sv', got %q", resp.Language)
|
||||
}
|
||||
if resp.Currency != "SEK" {
|
||||
t.Fatalf("expected Currency 'SEK', got %q", resp.Currency)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddAddress(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
id := testProfileID(t, applier)
|
||||
handler := CustomerHandler(applier)
|
||||
|
||||
body := `{
|
||||
"label": "Home",
|
||||
"fullName": "Alice",
|
||||
"addressLine1": "Storgatan 1",
|
||||
"addressLine2": "Lgh 101",
|
||||
"city": "Stockholm",
|
||||
"zip": "111 22",
|
||||
"country": "SE",
|
||||
"phone": "+46701234567"
|
||||
}`
|
||||
req := httptest.NewRequest("POST", fmt.Sprintf("/%s/addresses", id), strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 Created, got %d. Body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp CustomerResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if len(resp.Addresses) != 1 {
|
||||
t.Fatalf("expected 1 address, got %d", len(resp.Addresses))
|
||||
}
|
||||
if resp.Addresses[0].Label != "Home" {
|
||||
t.Fatalf("expected address label 'Home', got %q", resp.Addresses[0].Label)
|
||||
}
|
||||
if resp.Addresses[0].AddressLine1 != "Storgatan 1" {
|
||||
t.Fatalf("expected addressLine1 'Storgatan 1', got %q", resp.Addresses[0].AddressLine1)
|
||||
}
|
||||
if resp.Addresses[0].AddressLine2 != "Lgh 101" {
|
||||
t.Fatalf("expected addressLine2 'Lgh 101', got %q", resp.Addresses[0].AddressLine2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddAddressMissingRequired(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
id := testProfileID(t, applier)
|
||||
handler := CustomerHandler(applier)
|
||||
|
||||
// Missing addressLine1
|
||||
body := `{"city": "Stockholm", "zip": "111 22", "country": "SE"}`
|
||||
req := httptest.NewRequest("POST", fmt.Sprintf("/%s/addresses", id), strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 Bad Request for missing required fields, got %d. Body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAddress(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
id := testProfileID(t, applier)
|
||||
handler := CustomerHandler(applier)
|
||||
|
||||
// First add an address
|
||||
pid, _ := profile.ParseProfileId(id)
|
||||
applier.Apply(context.Background(), uint64(pid), &messages.AddAddress{
|
||||
Address: &messages.Address{
|
||||
Label: "Old Home",
|
||||
FullName: "Alice",
|
||||
AddressLine1: "Old Street 1",
|
||||
City: "Old City",
|
||||
Zip: "000 00",
|
||||
Country: "SE",
|
||||
},
|
||||
})
|
||||
|
||||
// Get the address id from the grain
|
||||
grain, _ := applier.Get(context.Background(), uint64(pid))
|
||||
if len(grain.Addresses) == 0 {
|
||||
t.Fatal("expected at least one address")
|
||||
}
|
||||
addrID := grain.Addresses[0].Id
|
||||
|
||||
body := fmt.Sprintf(`{"label": "New Home", "addressLine1": "New Street 2"}`)
|
||||
req := httptest.NewRequest("PUT", fmt.Sprintf("/%s/addresses/%d", id, addrID), strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp CustomerResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if len(resp.Addresses) != 1 {
|
||||
t.Fatalf("expected 1 address, got %d", len(resp.Addresses))
|
||||
}
|
||||
if resp.Addresses[0].Label != "New Home" {
|
||||
t.Fatalf("expected label 'New Home', got %q", resp.Addresses[0].Label)
|
||||
}
|
||||
if resp.Addresses[0].AddressLine1 != "New Street 2" {
|
||||
t.Fatalf("expected addressLine1 'New Street 2', got %q", resp.Addresses[0].AddressLine1)
|
||||
}
|
||||
// Unchanged fields should still be there
|
||||
if resp.Addresses[0].City != "Old City" {
|
||||
t.Fatalf("expected City 'Old City', got %q", resp.Addresses[0].City)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAddressInvalidID(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
id := testProfileID(t, applier)
|
||||
handler := CustomerHandler(applier)
|
||||
|
||||
body := `{"label": "New Home"}`
|
||||
req := httptest.NewRequest("PUT", fmt.Sprintf("/%s/addresses/abc", id), strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 Bad Request for invalid address id, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveAddress(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
id := testProfileID(t, applier)
|
||||
handler := CustomerHandler(applier)
|
||||
|
||||
// First add an address
|
||||
pid, _ := profile.ParseProfileId(id)
|
||||
applier.Apply(context.Background(), uint64(pid), &messages.AddAddress{
|
||||
Address: &messages.Address{
|
||||
Label: "Remove Me",
|
||||
FullName: "Alice",
|
||||
AddressLine1: "Storgatan 1",
|
||||
City: "Stockholm",
|
||||
Zip: "111 22",
|
||||
Country: "SE",
|
||||
},
|
||||
})
|
||||
|
||||
grain, _ := applier.Get(context.Background(), uint64(pid))
|
||||
if len(grain.Addresses) == 0 {
|
||||
t.Fatal("expected at least one address")
|
||||
}
|
||||
addrID := grain.Addresses[0].Id
|
||||
|
||||
// DELETE /customers/{id}/addresses/{addressId}
|
||||
req := httptest.NewRequest("DELETE", fmt.Sprintf("/%s/addresses/%d", id, addrID), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
grain, _ = applier.Get(context.Background(), uint64(pid))
|
||||
if len(grain.Addresses) != 0 {
|
||||
t.Fatalf("expected 0 addresses after removal, got %d", len(grain.Addresses))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveAddressNotFound(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
pid, err := profile.NewProfileId()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate profile id: %v", err)
|
||||
}
|
||||
applier.Get(context.Background(), uint64(pid))
|
||||
|
||||
id := pid.String()
|
||||
handler := CustomerHandler(applier)
|
||||
req := httptest.NewRequest("DELETE", fmt.Sprintf("/%s/addresses/999", id), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
// Test applier silently ignores not-found removals (returns no error).
|
||||
// In production, HandleRemoveAddress returns an error which maps to 404.
|
||||
// Accept 200 here since the test applier is a simplified mock.
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 (test applier limitation), got %d. Body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoveAddressNotFoundRealistic tests the real mutation handler path via the registry.
|
||||
// It uses the real HandleRemoveAddress mutation to verify 404 behavior.
|
||||
func TestRemoveAddressNotFoundMutation(t *testing.T) {
|
||||
// Test the real HandleRemoveAddress mutation directly.
|
||||
g := profile.NewProfileGrain(1, time.Now())
|
||||
|
||||
// Add an address via the real handler
|
||||
if err := profile.HandleAddAddress(g, &messages.AddAddress{
|
||||
Address: &messages.Address{
|
||||
Label: "Test",
|
||||
FullName: "Alice",
|
||||
AddressLine1: "Storgatan 1",
|
||||
City: "Stockholm",
|
||||
Zip: "111 22",
|
||||
Country: "SE",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("HandleAddAddress failed: %v", err)
|
||||
}
|
||||
|
||||
// Try to remove address 999 (doesn't exist)
|
||||
err := profile.HandleRemoveAddress(g, &messages.RemoveAddress{Id: 999})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent address, got nil")
|
||||
}
|
||||
expected := "RemoveAddress: address 999 not found"
|
||||
if err.Error() != expected {
|
||||
t.Fatalf("expected error %q, got %q", expected, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerHandlerInvalidID(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
handler := CustomerHandler(applier)
|
||||
|
||||
// Path "/" doesn't match the /{id} pattern — falls through to 404.
|
||||
// Paths with invalid base62 characters should get 400.
|
||||
tests := []struct {
|
||||
path string
|
||||
expectedStatus int
|
||||
}{
|
||||
{"/", http.StatusNotFound}, // doesn't match /{id}
|
||||
{"/!invalid!", http.StatusBadRequest}, // has non-base62 chars
|
||||
}
|
||||
for _, tt := range tests {
|
||||
req := httptest.NewRequest("GET", tt.path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != tt.expectedStatus {
|
||||
t.Fatalf("for path %q: expected %d, got %d. Body: %s", tt.path, tt.expectedStatus, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerHandlerThroughCombinedMount(t *testing.T) {
|
||||
// Test that the combined Handler correctly mounts customer endpoints.
|
||||
// Use a mock CartApplier (bare minimum — never called in this test).
|
||||
mockCart := &testCartApplier{}
|
||||
profileApplier := newTestProfileApplier()
|
||||
|
||||
pid, _ := profile.NewProfileId()
|
||||
profileApplier.Get(context.Background(), uint64(pid))
|
||||
|
||||
handler := Handler(mockCart, Options{
|
||||
ProfileApplier: profileApplier,
|
||||
})
|
||||
|
||||
// GET /customers/{id} should work
|
||||
id := pid.String()
|
||||
req := httptest.NewRequest("GET", fmt.Sprintf("/customers/%s", id), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp CustomerResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp.Id != id {
|
||||
t.Fatalf("expected id %q, got %q", id, resp.Id)
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,30 @@ func CheckoutHandler(applier CheckoutApplier, orderSvc ...OrderApplier) http.Han
|
||||
return withUCPAgent(withJSONContentType(mux))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Customer HTTP handler mounting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CustomerHandler returns an http.Handler that routes UCP customer/profile
|
||||
// REST endpoints. Mount it under any prefix (e.g. /ucp/v1) in a ServeMux:
|
||||
//
|
||||
// mux.Handle("/ucp/v1/customers", ucp.CustomerHandler(pool))
|
||||
// mux.Handle("/ucp/v1/customers/", ucp.CustomerHandler(pool))
|
||||
//
|
||||
// To sign responses with RFC 9421 HTTP Message Signatures, wrap with WithSigning:
|
||||
//
|
||||
// mux.Handle("/ucp/v1/customers", ucp.WithSigning(ucp.CustomerHandler(pool), signer))
|
||||
func CustomerHandler(applier ProfileApplier) http.Handler {
|
||||
s := NewCustomerServer(applier)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /{id}", s.handleGetCustomer)
|
||||
mux.HandleFunc("PUT /{id}", s.handleUpdateCustomer)
|
||||
mux.HandleFunc("POST /{id}/addresses", s.handleAddAddress)
|
||||
mux.HandleFunc("PUT /{id}/addresses/{addressId}", s.handleUpdateAddress)
|
||||
mux.HandleFunc("DELETE /{id}/addresses/{addressId}", s.handleRemoveAddress)
|
||||
return withUCPAgent(withJSONContentType(mux))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Combined handler for mounting all UCP endpoints under one prefix
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -81,6 +105,7 @@ func CheckoutHandler(applier CheckoutApplier, orderSvc ...OrderApplier) http.Han
|
||||
type Options struct {
|
||||
CheckoutApplier CheckoutApplier // when set, mounts /checkout-sessions
|
||||
OrderApplier OrderApplier // optional order creation for complete endpoint
|
||||
ProfileApplier ProfileApplier // when set, mounts /customers + enables identity linking
|
||||
}
|
||||
|
||||
// Handler returns an http.Handler that serves all mounted UCP endpoints under
|
||||
@@ -88,12 +113,16 @@ type Options struct {
|
||||
//
|
||||
// mux.Handle("/ucp/v1/", ucp.Handler(pool, ucp.Options{
|
||||
// CheckoutApplier: checkoutPool,
|
||||
// ProfileApplier: profilePool,
|
||||
// }))
|
||||
func Handler(cartApplier CartApplier, opts Options) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Cart endpoints
|
||||
cartSrv := NewCartServer(cartApplier)
|
||||
if opts.ProfileApplier != nil {
|
||||
cartSrv.SetProfileApplier(opts.ProfileApplier)
|
||||
}
|
||||
mux.HandleFunc("POST /carts", cartSrv.handleCreateCart)
|
||||
mux.HandleFunc("GET /carts/{id}", cartSrv.handleGetCart)
|
||||
mux.HandleFunc("PUT /carts/{id}", cartSrv.handleUpdateCart)
|
||||
@@ -106,6 +135,9 @@ func Handler(cartApplier CartApplier, opts Options) http.Handler {
|
||||
orderOpts = []OrderApplier{opts.OrderApplier}
|
||||
}
|
||||
checkoutSrv := NewCheckoutServer(opts.CheckoutApplier, orderOpts...)
|
||||
if opts.ProfileApplier != nil {
|
||||
checkoutSrv.SetProfileApplier(opts.ProfileApplier)
|
||||
}
|
||||
mux.HandleFunc("POST /checkout-sessions", checkoutSrv.handleCreateCheckout)
|
||||
mux.HandleFunc("GET /checkout-sessions/{id}", checkoutSrv.handleGetCheckout)
|
||||
mux.HandleFunc("PUT /checkout-sessions/{id}", checkoutSrv.handleUpdateCheckout)
|
||||
@@ -113,6 +145,16 @@ func Handler(cartApplier CartApplier, opts Options) http.Handler {
|
||||
mux.HandleFunc("POST /checkout-sessions/{id}/cancel", checkoutSrv.handleCancelCheckout)
|
||||
}
|
||||
|
||||
// Customer endpoints (optional)
|
||||
if opts.ProfileApplier != nil {
|
||||
customerSrv := NewCustomerServer(opts.ProfileApplier)
|
||||
mux.HandleFunc("GET /customers/{id}", customerSrv.handleGetCustomer)
|
||||
mux.HandleFunc("PUT /customers/{id}", customerSrv.handleUpdateCustomer)
|
||||
mux.HandleFunc("POST /customers/{id}/addresses", customerSrv.handleAddAddress)
|
||||
mux.HandleFunc("PUT /customers/{id}/addresses/{addressId}", customerSrv.handleUpdateAddress)
|
||||
mux.HandleFunc("DELETE /customers/{id}/addresses/{addressId}", customerSrv.handleRemoveAddress)
|
||||
}
|
||||
|
||||
return withUCPAgent(withJSONContentType(mux))
|
||||
}
|
||||
|
||||
|
||||
+86
-5
@@ -22,6 +22,7 @@ import (
|
||||
"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/profile"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
@@ -49,6 +50,12 @@ type OrderReadApplier interface {
|
||||
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[order.OrderGrain], error)
|
||||
}
|
||||
|
||||
// ProfileApplier is the minimal grain-pool interface the UCP customer adapter needs.
|
||||
type ProfileApplier interface {
|
||||
Get(ctx context.Context, id uint64) (*profile.ProfileGrain, error)
|
||||
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], 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.
|
||||
@@ -143,11 +150,12 @@ type Totals struct {
|
||||
|
||||
// CreateCheckoutRequest is the body for POST /checkout-sessions.
|
||||
type CreateCheckoutRequest struct {
|
||||
CartId string `json:"cartId"`
|
||||
Items []CartItemInput `json:"items,omitempty"`
|
||||
LineItems []CartItemInput `json:"line_items,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
CartId string `json:"cartId"`
|
||||
Items []CartItemInput `json:"items,omitempty"`
|
||||
LineItems []CartItemInput `json:"line_items,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
CustomerId string `json:"customerId,omitempty"` // profile ID for identity linking
|
||||
}
|
||||
|
||||
// UpdateCheckoutRequest is the body for PUT /checkout-sessions/{id}.
|
||||
@@ -187,6 +195,7 @@ type CompleteCheckoutRequest struct {
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
CustomerId string `json:"customerId,omitempty"` // profile ID for identity linking
|
||||
}
|
||||
|
||||
// CheckoutResponse is the UCP checkout session response.
|
||||
@@ -316,6 +325,78 @@ type OrderLineEntry struct {
|
||||
Quantity int `json:"quantity"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UCP Customer types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CustomerResponse is the UCP customer profile response body.
|
||||
type CustomerResponse struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
AvatarUrl string `json:"avatarUrl,omitempty"`
|
||||
Addresses []AddressResponse `json:"addresses,omitempty"`
|
||||
}
|
||||
|
||||
// AddressResponse is the UCP wire format for an address.
|
||||
type AddressResponse struct {
|
||||
Id uint32 `json:"id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
FullName string `json:"fullName,omitempty"`
|
||||
AddressLine1 string `json:"addressLine1"`
|
||||
AddressLine2 string `json:"addressLine2,omitempty"`
|
||||
City string `json:"city"`
|
||||
State string `json:"state,omitempty"`
|
||||
Zip string `json:"zip"`
|
||||
Country string `json:"country"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
IsDefaultShipping bool `json:"isDefaultShipping"`
|
||||
IsDefaultBilling bool `json:"isDefaultBilling"`
|
||||
}
|
||||
|
||||
// CustomerUpdateRequest is the body for PUT /customers/{id}.
|
||||
type CustomerUpdateRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
Currency *string `json:"currency,omitempty"`
|
||||
AvatarUrl *string `json:"avatarUrl,omitempty"`
|
||||
}
|
||||
|
||||
// AddAddressRequest is the body for POST /customers/{id}/addresses.
|
||||
type AddAddressRequest struct {
|
||||
Label string `json:"label,omitempty"`
|
||||
FullName string `json:"fullName,omitempty"`
|
||||
AddressLine1 string `json:"addressLine1"`
|
||||
AddressLine2 string `json:"addressLine2,omitempty"`
|
||||
City string `json:"city"`
|
||||
State string `json:"state,omitempty"`
|
||||
Zip string `json:"zip"`
|
||||
Country string `json:"country"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
IsDefaultShipping bool `json:"isDefaultShipping,omitempty"`
|
||||
IsDefaultBilling bool `json:"isDefaultBilling,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateAddressRequest is the body for PUT /customers/{id}/addresses/{addressId}.
|
||||
type UpdateAddressRequest struct {
|
||||
Label *string `json:"label,omitempty"`
|
||||
FullName *string `json:"fullName,omitempty"`
|
||||
AddressLine1 *string `json:"addressLine1,omitempty"`
|
||||
AddressLine2 *string `json:"addressLine2,omitempty"`
|
||||
City *string `json:"city,omitempty"`
|
||||
State *string `json:"state,omitempty"`
|
||||
Zip *string `json:"zip,omitempty"`
|
||||
Country *string `json:"country,omitempty"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
IsDefaultShipping *bool `json:"isDefaultShipping,omitempty"`
|
||||
IsDefaultBilling *bool `json:"isDefaultBilling,omitempty"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UCP Profile handler (/.well-known/ucp)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user