update customer
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 2s

This commit is contained in:
2026-06-25 21:09:58 +02:00
parent 10812d4203
commit c0c5a8bc0f
4 changed files with 83 additions and 6 deletions
+71 -1
View File
@@ -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{
+2 -2
View File
@@ -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 {
+2
View File
@@ -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)
+8 -3
View File
@@ -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
} }