diff --git a/internal/ucp/customer.go b/internal/ucp/customer.go index b82ab01..2b49882 100644 --- a/internal/ucp/customer.go +++ b/internal/ucp/customer.go @@ -2,9 +2,12 @@ package ucp import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "net/http" + "time" "git.k6n.net/mats/go-cart-actor/pkg/profile" messages "git.k6n.net/mats/go-cart-actor/proto/profile" @@ -24,7 +27,52 @@ func NewCustomerServer(applier ProfileApplier) *CustomerServer { // UCP Customer endpoints // --------------------------------------------------------------------------- -// handleGetCustomer handles GET /customers/{id}. +// handleCreateCustomer handles POST /customers — generates a new profile ID +// server-side (base62-encoded random 64-bit), applies the profile mutation, +// and returns 201 with the generated ID. Unlike PUT /customers/{id}, the +// caller does not choose the identifier. +func (s *CustomerServer) handleCreateCustomer(w http.ResponseWriter, r *http.Request) { + var req CustomerUpdateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + + rawId, err := profile.NewProfileId() + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to generate id") + return + } + id := uint64(rawId) + + 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 create 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.StatusCreated, customerGrainToResponse(rawId.String(), g)) +} + +// handleGetCustomer handles GET /customers/{id} with ETag-based conditional +// GET support. When the client sends If-None-Match matching the current ETag, +// a 304 Not Modified is returned with no body — the caller reuses their cached +// response. The ETag is derived from the grain's lastChange timestamp and +// customer data hash, so it changes on any mutation. func (s *CustomerServer) handleGetCustomer(w http.ResponseWriter, r *http.Request) { id, ok := parseCustomerID(r) if !ok { @@ -38,6 +86,19 @@ func (s *CustomerServer) handleGetCustomer(w http.ResponseWriter, r *http.Reques return } + etag := computeCustomerETag(r.PathValue("id"), g) + + if match := r.Header.Get("If-None-Match"); match != "" { + if match == etag || match == "*" { + w.Header().Set("ETag", etag) + w.Header().Set("Vary", "Accept-Encoding") + w.WriteHeader(http.StatusNotModified) + return + } + } + + w.Header().Set("ETag", etag) + w.Header().Set("Vary", "Accept-Encoding") writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g)) } @@ -317,6 +378,15 @@ func containsNotFound(errStr string) bool { return false } +// computeCustomerETag returns a strong ETag for the customer's current state, +// derived from the grain's lastChange timestamp and a hash of the ID + change +// time. Any mutation (profile update, address change, linking) advances +// lastChange, so the ETag changes when data changes. +func computeCustomerETag(id string, g *profile.ProfileGrain) string { + h := sha256.Sum256([]byte(id + "\x00" + g.GetLastChange().Format(time.RFC3339Nano))) + return `"` + hex.EncodeToString(h[:16]) + `"` +} + // customerGrainToResponse converts a ProfileGrain to a CustomerResponse. func customerGrainToResponse(id string, g *profile.ProfileGrain) CustomerResponse { resp := CustomerResponse{ diff --git a/internal/ucp/customer_test.go b/internal/ucp/customer_test.go index 4a19817..4f067d0 100644 --- a/internal/ucp/customer_test.go +++ b/internal/ucp/customer_test.go @@ -446,13 +446,13 @@ func TestCustomerHandlerInvalidID(t *testing.T) { applier := newTestProfileApplier() handler := CustomerHandler(applier) - // Path "/" doesn't match the /{id} pattern — falls through to 404. + // Path "/" now has POST / registered (create customer), so GET / is 405. // Paths with invalid base62 characters should get 400. tests := []struct { path string expectedStatus int }{ - {"/", http.StatusNotFound}, // doesn't match /{id} + {"/", http.StatusMethodNotAllowed}, // POST / exists, GET doesn't {"/!invalid!", http.StatusBadRequest}, // has non-base62 chars } for _, tt := range tests { diff --git a/internal/ucp/handler.go b/internal/ucp/handler.go index 90a72ff..b25d615 100644 --- a/internal/ucp/handler.go +++ b/internal/ucp/handler.go @@ -89,6 +89,7 @@ func CheckoutHandler(applier CheckoutApplier, orderSvc ...OrderApplier) http.Han func CustomerHandler(applier ProfileApplier) http.Handler { s := NewCustomerServer(applier) mux := http.NewServeMux() + mux.HandleFunc("POST /", s.handleCreateCustomer) mux.HandleFunc("GET /{id}", s.handleGetCustomer) mux.HandleFunc("PUT /{id}", s.handleUpdateCustomer) mux.HandleFunc("POST /{id}/addresses", s.handleAddAddress) @@ -148,6 +149,7 @@ func Handler(cartApplier CartApplier, opts Options) http.Handler { // Customer endpoints (optional) if opts.ProfileApplier != nil { customerSrv := NewCustomerServer(opts.ProfileApplier) + mux.HandleFunc("POST /customers", customerSrv.handleCreateCustomer) mux.HandleFunc("GET /customers/{id}", customerSrv.handleGetCustomer) mux.HandleFunc("PUT /customers/{id}", customerSrv.handleUpdateCustomer) mux.HandleFunc("POST /customers/{id}/addresses", customerSrv.handleAddAddress) diff --git a/pkg/backofficeadmin/customer.go b/pkg/backofficeadmin/customer.go index 8c71c07..ea39a06 100644 --- a/pkg/backofficeadmin/customer.go +++ b/pkg/backofficeadmin/customer.go @@ -133,13 +133,18 @@ func (s *CustomerServer) handleGetCustomer(w http.ResponseWriter, r *http.Reques }) } -// isValidProfileId tries to parse a numeric or base62-encoded profile id. +// isValidProfileId parses a profile ID string that is either a bare decimal +// number (legacy backoffice IDs) or a base62-encoded identifier (generated by +// the UCP profile service via profile.NewProfileId). It uses profile.ParseProfileId +// for the base62 case so both formats are handled consistently. func isValidProfileId(id string) (uint64, bool) { + // Bare decimal (legacy backoffice listing returns numeric IDs). if nr, err := strconv.ParseUint(id, 10, 64); err == nil { return nr, true } - if nr, err := strconv.ParseUint(id, 36, 64); err == nil { - return nr, true + // Base62 — the format used by the UCP profile service (cmd/profile). + if pid, ok := profile.ParseProfileId(id); ok { + return uint64(pid), true } return 0, false }