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 (
"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{
+2 -2
View File
@@ -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 {
+2
View File
@@ -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)