update customer
This commit is contained in:
@@ -2,9 +2,12 @@ package ucp
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/profile"
|
messages "git.k6n.net/mats/go-cart-actor/proto/profile"
|
||||||
@@ -24,7 +27,52 @@ func NewCustomerServer(applier ProfileApplier) *CustomerServer {
|
|||||||
// UCP Customer endpoints
|
// 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) {
|
func (s *CustomerServer) handleGetCustomer(w http.ResponseWriter, r *http.Request) {
|
||||||
id, ok := parseCustomerID(r)
|
id, ok := parseCustomerID(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -38,6 +86,19 @@ func (s *CustomerServer) handleGetCustomer(w http.ResponseWriter, r *http.Reques
|
|||||||
return
|
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))
|
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,6 +378,15 @@ func containsNotFound(errStr string) bool {
|
|||||||
return false
|
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.
|
// customerGrainToResponse converts a ProfileGrain to a CustomerResponse.
|
||||||
func customerGrainToResponse(id string, g *profile.ProfileGrain) CustomerResponse {
|
func customerGrainToResponse(id string, g *profile.ProfileGrain) CustomerResponse {
|
||||||
resp := CustomerResponse{
|
resp := CustomerResponse{
|
||||||
|
|||||||
@@ -446,13 +446,13 @@ func TestCustomerHandlerInvalidID(t *testing.T) {
|
|||||||
applier := newTestProfileApplier()
|
applier := newTestProfileApplier()
|
||||||
handler := CustomerHandler(applier)
|
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.
|
// Paths with invalid base62 characters should get 400.
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
path string
|
path string
|
||||||
expectedStatus int
|
expectedStatus int
|
||||||
}{
|
}{
|
||||||
{"/", http.StatusNotFound}, // doesn't match /{id}
|
{"/", http.StatusMethodNotAllowed}, // POST / exists, GET doesn't
|
||||||
{"/!invalid!", http.StatusBadRequest}, // has non-base62 chars
|
{"/!invalid!", http.StatusBadRequest}, // has non-base62 chars
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ func CheckoutHandler(applier CheckoutApplier, orderSvc ...OrderApplier) http.Han
|
|||||||
func CustomerHandler(applier ProfileApplier) http.Handler {
|
func CustomerHandler(applier ProfileApplier) http.Handler {
|
||||||
s := NewCustomerServer(applier)
|
s := NewCustomerServer(applier)
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("POST /", s.handleCreateCustomer)
|
||||||
mux.HandleFunc("GET /{id}", s.handleGetCustomer)
|
mux.HandleFunc("GET /{id}", s.handleGetCustomer)
|
||||||
mux.HandleFunc("PUT /{id}", s.handleUpdateCustomer)
|
mux.HandleFunc("PUT /{id}", s.handleUpdateCustomer)
|
||||||
mux.HandleFunc("POST /{id}/addresses", s.handleAddAddress)
|
mux.HandleFunc("POST /{id}/addresses", s.handleAddAddress)
|
||||||
@@ -148,6 +149,7 @@ func Handler(cartApplier CartApplier, opts Options) http.Handler {
|
|||||||
// Customer endpoints (optional)
|
// Customer endpoints (optional)
|
||||||
if opts.ProfileApplier != nil {
|
if opts.ProfileApplier != nil {
|
||||||
customerSrv := NewCustomerServer(opts.ProfileApplier)
|
customerSrv := NewCustomerServer(opts.ProfileApplier)
|
||||||
|
mux.HandleFunc("POST /customers", customerSrv.handleCreateCustomer)
|
||||||
mux.HandleFunc("GET /customers/{id}", customerSrv.handleGetCustomer)
|
mux.HandleFunc("GET /customers/{id}", customerSrv.handleGetCustomer)
|
||||||
mux.HandleFunc("PUT /customers/{id}", customerSrv.handleUpdateCustomer)
|
mux.HandleFunc("PUT /customers/{id}", customerSrv.handleUpdateCustomer)
|
||||||
mux.HandleFunc("POST /customers/{id}/addresses", customerSrv.handleAddAddress)
|
mux.HandleFunc("POST /customers/{id}/addresses", customerSrv.handleAddAddress)
|
||||||
|
|||||||
@@ -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) {
|
func isValidProfileId(id string) (uint64, bool) {
|
||||||
|
// Bare decimal (legacy backoffice listing returns numeric IDs).
|
||||||
if nr, err := strconv.ParseUint(id, 10, 64); err == nil {
|
if nr, err := strconv.ParseUint(id, 10, 64); err == nil {
|
||||||
return nr, true
|
return nr, true
|
||||||
}
|
}
|
||||||
if nr, err := strconv.ParseUint(id, 36, 64); err == nil {
|
// Base62 — the format used by the UCP profile service (cmd/profile).
|
||||||
return nr, true
|
if pid, ok := profile.ParseProfileId(id); ok {
|
||||||
|
return uint64(pid), true
|
||||||
}
|
}
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user