order sagas
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/order"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func nowMs() int64 { return time.Now().UnixMilli() }
|
||||
|
||||
// applyOne applies a single mutation and surfaces the handler error (the pool
|
||||
// returns a top-level error only for unregistered mutations; a rejected
|
||||
// transition is carried per-mutation in the result).
|
||||
func applyOne(ctx context.Context, app *orderedApplier, id uint64, msg proto.Message) error {
|
||||
res, err := app.Apply(ctx, id, msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res != nil {
|
||||
for _, m := range res.Mutations {
|
||||
if m.Error != nil {
|
||||
return m.Error
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// capturedRef returns the capture reference of the first captured payment, which
|
||||
// the payment provider needs to issue a refund against.
|
||||
func capturedRef(g *order.OrderGrain) string {
|
||||
for _, p := range g.Payments {
|
||||
if p.Captured > 0 && p.CaptureRef != "" {
|
||||
return p.CaptureRef
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// loadOrder parses the id and returns the current grain, writing 400/404 itself
|
||||
// and returning ok=false when the caller should stop.
|
||||
func (s *server) loadOrder(w http.ResponseWriter, r *http.Request) (order.OrderId, *order.OrderGrain, bool) {
|
||||
id, ok := order.ParseOrderId(r.PathValue("id"))
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid order id"))
|
||||
return 0, nil, false
|
||||
}
|
||||
g, err := s.applier.Get(r.Context(), uint64(id))
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return 0, nil, false
|
||||
}
|
||||
if g.Status == order.StatusNew {
|
||||
writeErr(w, http.StatusNotFound, fmt.Errorf("order not found"))
|
||||
return 0, nil, false
|
||||
}
|
||||
return id, g, true
|
||||
}
|
||||
|
||||
// applyAndRespond applies a mutation and returns the updated order. A rejected
|
||||
// state transition (handler error) maps to 409 Conflict.
|
||||
func (s *server) applyAndRespond(w http.ResponseWriter, r *http.Request, id order.OrderId, msg proto.Message) {
|
||||
res, err := s.applier.Apply(r.Context(), uint64(id), msg)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
for _, m := range res.Mutations {
|
||||
if m.Error != nil {
|
||||
writeErr(w, http.StatusConflict, m.Error)
|
||||
return
|
||||
}
|
||||
}
|
||||
grain, err := s.applier.Get(r.Context(), uint64(id))
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"orderId": id.String(), "order": grain})
|
||||
}
|
||||
|
||||
// --- lifecycle (b) --------------------------------------------------------
|
||||
|
||||
type fulfillReq struct {
|
||||
Carrier string `json:"carrier,omitempty"`
|
||||
TrackingNumber string `json:"trackingNumber,omitempty"`
|
||||
TrackingURI string `json:"trackingUri,omitempty"`
|
||||
Lines []struct {
|
||||
Reference string `json:"reference"`
|
||||
Quantity int32 `json:"quantity"`
|
||||
} `json:"lines"`
|
||||
}
|
||||
|
||||
func (s *server) handleFulfill(w http.ResponseWriter, r *http.Request) {
|
||||
id, _, ok := s.loadOrder(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req fulfillReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
fid, _ := order.NewOrderId()
|
||||
msg := &messages.CreateFulfillment{
|
||||
Id: "f_" + fid.String(),
|
||||
Carrier: req.Carrier,
|
||||
TrackingNumber: req.TrackingNumber,
|
||||
TrackingUri: req.TrackingURI,
|
||||
AtMs: nowMs(),
|
||||
}
|
||||
for _, l := range req.Lines {
|
||||
msg.Lines = append(msg.Lines, &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity})
|
||||
}
|
||||
s.applyAndRespond(w, r, id, msg)
|
||||
}
|
||||
|
||||
func (s *server) handleComplete(w http.ResponseWriter, r *http.Request) {
|
||||
id, _, ok := s.loadOrder(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.applyAndRespond(w, r, id, &messages.CompleteOrder{AtMs: nowMs()})
|
||||
}
|
||||
|
||||
func (s *server) handleCancel(w http.ResponseWriter, r *http.Request) {
|
||||
id, _, ok := s.loadOrder(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
s.applyAndRespond(w, r, id, &messages.CancelOrder{Reason: req.Reason, AtMs: nowMs()})
|
||||
}
|
||||
|
||||
type returnReq struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Lines []struct {
|
||||
Reference string `json:"reference"`
|
||||
Quantity int32 `json:"quantity"`
|
||||
} `json:"lines"`
|
||||
}
|
||||
|
||||
func (s *server) handleReturn(w http.ResponseWriter, r *http.Request) {
|
||||
id, _, ok := s.loadOrder(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req returnReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
rid, _ := order.NewOrderId()
|
||||
msg := &messages.RequestReturn{Id: "r_" + rid.String(), Reason: req.Reason, AtMs: nowMs()}
|
||||
for _, l := range req.Lines {
|
||||
msg.Lines = append(msg.Lines, &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity})
|
||||
}
|
||||
s.applyAndRespond(w, r, id, msg)
|
||||
}
|
||||
|
||||
func (s *server) handleRefund(w http.ResponseWriter, r *http.Request) {
|
||||
id, g, ok := s.loadOrder(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Amount int64 `json:"amount"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
amount := req.Amount
|
||||
if amount <= 0 {
|
||||
amount = g.CapturedAmount - g.RefundedAmount // default: full remaining
|
||||
}
|
||||
captureRef := capturedRef(g)
|
||||
ref, err := s.provider.Refund(r.Context(), captureRef, amount)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadGateway, fmt.Errorf("refund failed: %w", err))
|
||||
return
|
||||
}
|
||||
s.applyAndRespond(w, r, id, &messages.IssueRefund{
|
||||
Provider: ref.Provider,
|
||||
Amount: ref.Amount,
|
||||
Reference: ref.Reference,
|
||||
AtMs: nowMs(),
|
||||
})
|
||||
}
|
||||
|
||||
// --- saga / flow admin (a) ------------------------------------------------
|
||||
|
||||
func (s *server) handleCapabilities(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.freg.Capabilities())
|
||||
}
|
||||
|
||||
func (s *server) handleListFlows(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.flows.list())
|
||||
}
|
||||
|
||||
func (s *server) handleGetFlow(w http.ResponseWriter, r *http.Request) {
|
||||
def, ok := s.flows.get(r.PathValue("name"))
|
||||
if !ok {
|
||||
writeErr(w, http.StatusNotFound, fmt.Errorf("flow not found"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, def)
|
||||
}
|
||||
|
||||
func (s *server) handleSaveFlow(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
var def flow.Definition
|
||||
if err := json.NewDecoder(r.Body).Decode(&def); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := s.flows.save(name, &def); err != nil {
|
||||
// Validation failures (unknown action/hook/predicate) are client errors.
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, &def)
|
||||
}
|
||||
Reference in New Issue
Block a user