cart actor
This commit is contained in:
@@ -1,439 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/go-cart-actor/pkg/actor"
|
||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/go-cart-actor/pkg/checkout"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type FileServer struct {
|
||||
// Define fields here
|
||||
dataDir string
|
||||
checkoutDataDir string
|
||||
storage actor.LogStorage[cart.CartGrain]
|
||||
checkoutStorage actor.LogStorage[checkout.CheckoutGrain]
|
||||
}
|
||||
|
||||
func NewFileServer(dataDir string, checkoutDataDir string, storage actor.LogStorage[cart.CartGrain], checkoutStorage actor.LogStorage[checkout.CheckoutGrain]) *FileServer {
|
||||
return &FileServer{
|
||||
dataDir: dataDir,
|
||||
checkoutDataDir: checkoutDataDir,
|
||||
storage: storage,
|
||||
checkoutStorage: checkoutStorage,
|
||||
}
|
||||
}
|
||||
|
||||
func isValidId(id string) (uint64, bool) {
|
||||
if nr, err := strconv.ParseUint(id, 10, 64); err == nil {
|
||||
return nr, true
|
||||
}
|
||||
if nr, ok := cart.ParseCartId(id); ok {
|
||||
return uint64(nr), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func isValidFileId(name string) (uint64, bool) {
|
||||
|
||||
parts := strings.Split(name, ".")
|
||||
if len(parts) > 1 && parts[1] == "events" {
|
||||
idStr := parts[0]
|
||||
|
||||
return isValidId(idStr)
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// func AccessTime(info os.FileInfo) (time.Time, bool) {
|
||||
// switch stat := info.Sys().(type) {
|
||||
// case *syscall.Stat_t:
|
||||
// // Linux: Atim; macOS/BSD: Atimespec
|
||||
// // Use reflection or build tags if naming differs.
|
||||
// // Linux:
|
||||
// if stat.Atim.Sec != 0 || stat.Atim.Nsec != 0 {
|
||||
// return time.Unix(int64(stat.Atim.Sec), int64(stat.Atim.Nsec)), true
|
||||
// }
|
||||
// // macOS/BSD example (uncomment if needed):
|
||||
// //return time.Unix(int64(stat.Atimespec.Sec), int64(stat.Atimespec.Nsec)), true
|
||||
// }
|
||||
// return time.Time{}, false
|
||||
// }
|
||||
|
||||
func appendFileInfo(info fs.FileInfo, out *CartFileInfo) *CartFileInfo {
|
||||
//sys := info.Sys()
|
||||
//fmt.Printf("sys type %T", sys)
|
||||
out.Size = info.Size()
|
||||
out.Modified = info.ModTime()
|
||||
//out.Accessed, _ = AccessTime(info)
|
||||
return out
|
||||
}
|
||||
|
||||
func appendCheckoutFileInfo(info fs.FileInfo, out *CheckoutFileInfo) *CheckoutFileInfo {
|
||||
out.Size = info.Size()
|
||||
out.Modified = info.ModTime()
|
||||
return out
|
||||
}
|
||||
|
||||
// var cartFileRe = regexp.MustCompile(`^(\d+)\.events\.log$`)
|
||||
|
||||
func listCartFiles(dir string) ([]*CartFileInfo, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []*CartFileInfo{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
out := make([]*CartFileInfo, 0)
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
id, valid := isValidFileId(e.Name())
|
||||
if !valid {
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
info.Sys()
|
||||
out = append(out, appendFileInfo(info, &CartFileInfo{
|
||||
ID: fmt.Sprintf("%d", id),
|
||||
CartId: cart.CartId(id),
|
||||
}))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func listCheckoutFiles(dir string) ([]*CheckoutFileInfo, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []*CheckoutFileInfo{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
out := make([]*CheckoutFileInfo, 0)
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
id, valid := isValidFileId(e.Name())
|
||||
if !valid {
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, appendCheckoutFileInfo(info, &CheckoutFileInfo{
|
||||
ID: fmt.Sprintf("%d", id),
|
||||
CheckoutId: checkout.CheckoutId(id),
|
||||
}))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func readRawLogLines(path string) ([]json.RawMessage, error) {
|
||||
fh, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer fh.Close()
|
||||
lines := make([]json.RawMessage, 0, 64)
|
||||
s := bufio.NewScanner(fh)
|
||||
// increase buffer to handle larger JSON lines
|
||||
buf := make([]byte, 0, 1024*1024)
|
||||
s.Buffer(buf, 1024*1024)
|
||||
for s.Scan() {
|
||||
line := s.Bytes()
|
||||
if line == nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
if err := s.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func (fs *FileServer) CartsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := listCartFiles(fs.dataDir)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// sort by modified desc
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].Modified.After(list[j].Modified) })
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"count": len(list),
|
||||
"carts": list,
|
||||
})
|
||||
}
|
||||
|
||||
func (fs *FileServer) CheckoutsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := listCheckoutFiles(fs.checkoutDataDir)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// sort by modified desc
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].Modified.After(list[j].Modified) })
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"count": len(list),
|
||||
"checkouts": list,
|
||||
})
|
||||
}
|
||||
|
||||
func (fs *FileServer) PromotionsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
fileName := filepath.Join(fs.dataDir, "promotions.json")
|
||||
if r.Method == http.MethodGet {
|
||||
file, err := os.Open(fileName)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
io.Copy(w, file)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost {
|
||||
file, err := os.Create(fileName)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
io.Copy(file, r.Body)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
func (fs *FileServer) VoucherHandler(w http.ResponseWriter, r *http.Request) {
|
||||
fileName := filepath.Join(fs.dataDir, "vouchers.json")
|
||||
if r.Method == http.MethodGet {
|
||||
file, err := os.Open(fileName)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
io.Copy(w, file)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost {
|
||||
file, err := os.Create(fileName)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
io.Copy(file, r.Body)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
func (fs *FileServer) PromotionPartHandler(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
if idStr == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, "missing id")
|
||||
return
|
||||
}
|
||||
_, ok := isValidId(idStr)
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, "invalid id %s", idStr)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
type JsonError struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func acceptAll(_ proto.Message, _ int, _ time.Time) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func acceptUntilIndex(maxIndex int) func(msg proto.Message, index int, when time.Time) bool {
|
||||
return func(msg proto.Message, index int, when time.Time) bool {
|
||||
return index < maxIndex
|
||||
}
|
||||
}
|
||||
|
||||
func acceptUntilTimestamp(until time.Time) func(msg proto.Message, index int, when time.Time) bool {
|
||||
return func(msg proto.Message, index int, when time.Time) bool {
|
||||
return when.Before(until)
|
||||
}
|
||||
}
|
||||
|
||||
func (fs *FileServer) CartHandler(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
if idStr == "" {
|
||||
writeJSON(w, http.StatusBadRequest, JsonError{Error: "missing id"})
|
||||
return
|
||||
}
|
||||
id, ok := isValidId(idStr)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid id"})
|
||||
return
|
||||
}
|
||||
// parse query parameters for filtering
|
||||
query := r.URL.Query()
|
||||
filterFunction := acceptAll
|
||||
if maxIndexStr := query.Get("maxIndex"); maxIndexStr != "" {
|
||||
log.Printf("filter maxIndex: %s", maxIndexStr)
|
||||
maxIndex, err := strconv.Atoi(maxIndexStr)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid maxIndex"})
|
||||
return
|
||||
}
|
||||
filterFunction = acceptUntilIndex(maxIndex)
|
||||
} else if untilStr := query.Get("until"); untilStr != "" {
|
||||
log.Printf("filter until: %s", untilStr)
|
||||
until, err := time.Parse(time.RFC3339, untilStr)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid until timestamp"})
|
||||
return
|
||||
}
|
||||
filterFunction = acceptUntilTimestamp(until)
|
||||
}
|
||||
// reconstruct state from event log if present
|
||||
grain := cart.NewCartGrain(id, time.Now())
|
||||
err := fs.storage.LoadEventsFunc(r.Context(), id, grain, filterFunction)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
path := filepath.Join(fs.dataDir, fmt.Sprintf("%d.events.log", id))
|
||||
info, err := os.Stat(path)
|
||||
if err != nil && errors.Is(err, os.ErrNotExist) {
|
||||
writeJSON(w, http.StatusNotFound, JsonError{Error: "cart not found"})
|
||||
return
|
||||
} else if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
lines, err := readRawLogLines(path)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": id,
|
||||
"cartId": cart.CartId(id).String(),
|
||||
"state": grain,
|
||||
"mutations": lines,
|
||||
"meta": map[string]any{
|
||||
"size": info.Size(),
|
||||
"modified": info.ModTime(),
|
||||
"path": path,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (fs *FileServer) CheckoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
if idStr == "" {
|
||||
writeJSON(w, http.StatusBadRequest, JsonError{Error: "missing id"})
|
||||
return
|
||||
}
|
||||
id, ok := isValidId(idStr)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid id"})
|
||||
return
|
||||
}
|
||||
// parse query parameters for filtering
|
||||
query := r.URL.Query()
|
||||
filterFunction := acceptAll
|
||||
if maxIndexStr := query.Get("maxIndex"); maxIndexStr != "" {
|
||||
log.Printf("filter maxIndex: %s", maxIndexStr)
|
||||
maxIndex, err := strconv.Atoi(maxIndexStr)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid maxIndex"})
|
||||
return
|
||||
}
|
||||
filterFunction = acceptUntilIndex(maxIndex)
|
||||
} else if untilStr := query.Get("until"); untilStr != "" {
|
||||
log.Printf("filter until: %s", untilStr)
|
||||
until, err := time.Parse(time.RFC3339, untilStr)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid until timestamp"})
|
||||
return
|
||||
}
|
||||
filterFunction = acceptUntilTimestamp(until)
|
||||
}
|
||||
// reconstruct state from event log if present
|
||||
grain := checkout.NewCheckoutGrain(id, cart.CartId(id), 0, time.Now(), nil)
|
||||
err := fs.checkoutStorage.LoadEventsFunc(r.Context(), id, grain, filterFunction)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
path := filepath.Join(fs.checkoutDataDir, fmt.Sprintf("%d.events.log", id))
|
||||
info, err := os.Stat(path)
|
||||
if err != nil && errors.Is(err, os.ErrNotExist) {
|
||||
writeJSON(w, http.StatusNotFound, JsonError{Error: "checkout not found"})
|
||||
return
|
||||
} else if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
lines, err := readRawLogLines(path)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": id,
|
||||
"checkoutId": checkout.CheckoutId(id).String(),
|
||||
"state": grain,
|
||||
"mutations": lines,
|
||||
"meta": map[string]any{
|
||||
"size": info.Size(),
|
||||
"modified": info.ModTime(),
|
||||
"path": path,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
||||
)
|
||||
|
||||
// TestAppendFileInfoRandomProjectFile picks a random existing .go source file in the
|
||||
// repository (from a small curated list to keep the test hermetic) and verifies
|
||||
// that appendFileInfo populates Size, Modified and System without mutating the
|
||||
// identity fields (ID, CartId). The randomness is only to satisfy the requirement
|
||||
// of using "a random project file"; the test behavior is deterministic enough for
|
||||
// CI because all chosen files are expected to exist.
|
||||
func TestAppendFileInfoRandomProjectFile(t *testing.T) {
|
||||
candidates := []string{
|
||||
filepath.FromSlash("../../pkg/cart/cart_id.go"),
|
||||
filepath.FromSlash("../../pkg/actor/grain.go"),
|
||||
filepath.FromSlash("../../cmd/cart/main.go"),
|
||||
}
|
||||
// Pick one at random.
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
path := candidates[rand.Intn(len(candidates))]
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat failed for %s: %v", path, err)
|
||||
}
|
||||
|
||||
// Pre-populate a CartFileInfo with identity fields.
|
||||
origID := "test-id"
|
||||
origCartId := cart.CartId(12345)
|
||||
cf := &CartFileInfo{ID: origID, CartId: origCartId}
|
||||
|
||||
// Call function under test.
|
||||
got := appendFileInfo(info, cf)
|
||||
|
||||
if got != cf {
|
||||
t.Fatalf("appendFileInfo should return the same pointer instance")
|
||||
}
|
||||
|
||||
if cf.ID != origID {
|
||||
t.Fatalf("ID mutated: expected %q got %q", origID, cf.ID)
|
||||
}
|
||||
if cf.CartId != origCartId {
|
||||
t.Fatalf("CartId mutated: expected %v got %v", origCartId, cf.CartId)
|
||||
}
|
||||
|
||||
if cf.Size != info.Size() {
|
||||
t.Fatalf("Size mismatch: expected %d got %d", info.Size(), cf.Size)
|
||||
}
|
||||
|
||||
mod := info.ModTime()
|
||||
// Allow small clock skew / coarse timestamp truncation.
|
||||
if cf.Modified.Before(mod.Add(-2*time.Second)) || cf.Modified.After(mod.Add(2*time.Second)) {
|
||||
t.Fatalf("Modified not within expected range: want ~%v got %v", mod, cf.Modified)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// TestAppendFileInfoTempFile creates a temporary file to ensure Size and Modified
|
||||
// are updated for a freshly written file with known content length.
|
||||
func TestAppendFileInfoTempFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "temp.events.log")
|
||||
content := []byte("hello world\nanother line\n")
|
||||
if err := os.WriteFile(path, content, 0644); err != nil {
|
||||
t.Fatalf("write temp file failed: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat temp file failed: %v", err)
|
||||
}
|
||||
|
||||
cf := &CartFileInfo{ID: "temp", CartId: cart.CartId(0)}
|
||||
appendFileInfo(info, cf)
|
||||
|
||||
if cf.Size != int64(len(content)) {
|
||||
t.Fatalf("expected Size %d got %d", len(content), cf.Size)
|
||||
}
|
||||
if cf.Modified.IsZero() {
|
||||
t.Fatalf("Modified should be set")
|
||||
}
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Hub manages websocket clients and broadcasts messages to them.
|
||||
type Hub struct {
|
||||
register chan *Client
|
||||
unregister chan *Client
|
||||
broadcast chan []byte
|
||||
clients map[*Client]bool
|
||||
}
|
||||
|
||||
// Client represents a single websocket client connection.
|
||||
type Client struct {
|
||||
hub *Hub
|
||||
conn net.Conn
|
||||
send chan []byte
|
||||
}
|
||||
|
||||
// NewHub constructs a new Hub instance.
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
register: make(chan *Client),
|
||||
unregister: make(chan *Client),
|
||||
broadcast: make(chan []byte, 1024),
|
||||
clients: make(map[*Client]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the hub event loop.
|
||||
func (h *Hub) Run() {
|
||||
for {
|
||||
select {
|
||||
case c := <-h.register:
|
||||
h.clients[c] = true
|
||||
case c := <-h.unregister:
|
||||
if _, ok := h.clients[c]; ok {
|
||||
delete(h.clients, c)
|
||||
close(c.send)
|
||||
_ = c.conn.Close()
|
||||
}
|
||||
case msg := <-h.broadcast:
|
||||
for c := range h.clients {
|
||||
select {
|
||||
case c.send <- msg:
|
||||
default:
|
||||
// Client is slow or dead; drop it.
|
||||
delete(h.clients, c)
|
||||
close(c.send)
|
||||
_ = c.conn.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// computeAccept computes the Sec-WebSocket-Accept header value.
|
||||
func computeAccept(key string) string {
|
||||
const magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||||
h := sha1.New()
|
||||
h.Write([]byte(key + magic))
|
||||
return base64.StdEncoding.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// ServeWS upgrades the HTTP request to a WebSocket connection and registers a client.
|
||||
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") || strings.ToLower(r.Header.Get("Upgrade")) != "websocket" {
|
||||
http.Error(w, "upgrade required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
key := r.Header.Get("Sec-WebSocket-Key")
|
||||
if key == "" {
|
||||
http.Error(w, "missing Sec-WebSocket-Key", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
accept := computeAccept(key)
|
||||
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "websocket not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
conn, buf, err := hj.Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Write the upgrade response
|
||||
response := "HTTP/1.1 101 Switching Protocols\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Connection: Upgrade\r\n" +
|
||||
"Sec-WebSocket-Accept: " + accept + "\r\n" +
|
||||
"\r\n"
|
||||
if _, err := buf.WriteString(response); err != nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
if err := buf.Flush(); err != nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
hub: h,
|
||||
conn: conn,
|
||||
send: make(chan []byte, 256),
|
||||
}
|
||||
h.register <- client
|
||||
go client.writePump()
|
||||
go client.readPump()
|
||||
}
|
||||
|
||||
// writeWSFrame writes a single WebSocket frame to the writer.
|
||||
func writeWSFrame(w io.Writer, opcode byte, payload []byte) error {
|
||||
// FIN set, opcode as provided
|
||||
header := []byte{0x80 | (opcode & 0x0F)}
|
||||
l := len(payload)
|
||||
switch {
|
||||
case l < 126:
|
||||
header = append(header, byte(l))
|
||||
case l <= 65535:
|
||||
ext := make([]byte, 2)
|
||||
binary.BigEndian.PutUint16(ext, uint16(l))
|
||||
header = append(header, 126)
|
||||
header = append(header, ext...)
|
||||
default:
|
||||
ext := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(ext, uint64(l))
|
||||
header = append(header, 127)
|
||||
header = append(header, ext...)
|
||||
}
|
||||
if _, err := w.Write(header); err != nil {
|
||||
return err
|
||||
}
|
||||
if l > 0 {
|
||||
if _, err := w.Write(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readPump handles control frames from the client and discards other incoming frames.
|
||||
// This server is broadcast-only to clients.
|
||||
func (c *Client) readPump() {
|
||||
defer func() {
|
||||
c.hub.unregister <- c
|
||||
}()
|
||||
reader := bufio.NewReader(c.conn)
|
||||
for {
|
||||
// Read first two bytes
|
||||
b1, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
b2, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
opcode := b1 & 0x0F
|
||||
masked := (b2 & 0x80) != 0
|
||||
length := int64(b2 & 0x7F)
|
||||
if length == 126 {
|
||||
ext := make([]byte, 2)
|
||||
if _, err := io.ReadFull(reader, ext); err != nil {
|
||||
return
|
||||
}
|
||||
length = int64(binary.BigEndian.Uint16(ext))
|
||||
} else if length == 127 {
|
||||
ext := make([]byte, 8)
|
||||
if _, err := io.ReadFull(reader, ext); err != nil {
|
||||
return
|
||||
}
|
||||
length = int64(binary.BigEndian.Uint64(ext))
|
||||
}
|
||||
var maskKey [4]byte
|
||||
if masked {
|
||||
if _, err := io.ReadFull(reader, maskKey[:]); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Ping -> Pong
|
||||
if opcode == 0x9 && length <= 125 {
|
||||
payload := make([]byte, length)
|
||||
if _, err := io.ReadFull(reader, payload); err != nil {
|
||||
return
|
||||
}
|
||||
// Unmask if masked
|
||||
if masked {
|
||||
for i := int64(0); i < length; i++ {
|
||||
payload[i] ^= maskKey[i%4]
|
||||
}
|
||||
}
|
||||
_ = writeWSFrame(c.conn, 0xA, payload) // best-effort pong
|
||||
continue
|
||||
}
|
||||
|
||||
// Close frame
|
||||
if opcode == 0x8 {
|
||||
// Drain payload if any, then exit
|
||||
if _, err := io.CopyN(io.Discard, reader, length); err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// For other frames, just discard payload
|
||||
if _, err := io.CopyN(io.Discard, reader, length); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writePump sends queued messages to the client and pings periodically to keep the connection alive.
|
||||
func (c *Client) writePump() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
_ = c.conn.Close()
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case msg, ok := <-c.send:
|
||||
if !ok {
|
||||
// try to send close frame
|
||||
_ = writeWSFrame(c.conn, 0x8, nil)
|
||||
return
|
||||
}
|
||||
if err := writeWSFrame(c.conn, 0x1, msg); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
// Send a ping to keep connections alive behind proxies
|
||||
_ = writeWSFrame(c.conn, 0x9, []byte("ping"))
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-135
@@ -2,36 +2,16 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
actor "git.k6n.net/go-cart-actor/pkg/actor"
|
||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/go-cart-actor/pkg/checkout"
|
||||
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
||||
"github.com/matst80/slask-finder/pkg/messaging"
|
||||
"git.k6n.net/go-cart-actor/pkg/backofficeadmin"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type CartFileInfo struct {
|
||||
ID string `json:"id"`
|
||||
CartId cart.CartId `json:"cartId"`
|
||||
Size int64 `json:"size"`
|
||||
Modified time.Time `json:"modified"`
|
||||
}
|
||||
|
||||
type CheckoutFileInfo struct {
|
||||
ID string `json:"id"`
|
||||
CheckoutId checkout.CheckoutId `json:"checkoutId"`
|
||||
Size int64 `json:"size"`
|
||||
Modified time.Time `json:"modified"`
|
||||
}
|
||||
|
||||
func envOrDefault(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
@@ -39,119 +19,22 @@ func envOrDefault(key, def string) string {
|
||||
return def
|
||||
}
|
||||
|
||||
func startMutationConsumer(ctx context.Context, conn *amqp.Connection, hub *Hub) error {
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return err
|
||||
}
|
||||
msgs, err := messaging.DeclareBindAndConsume(ch, "cart", "mutation")
|
||||
if err != nil {
|
||||
_ = ch.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer ch.Close()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case m, ok := <-msgs:
|
||||
if !ok {
|
||||
log.Fatalf("connection closed")
|
||||
continue
|
||||
}
|
||||
// Log and broadcast to all websocket clients
|
||||
log.Printf("mutation event: %s", string(m.Body))
|
||||
|
||||
if hub != nil {
|
||||
select {
|
||||
case hub.broadcast <- m.Body:
|
||||
default:
|
||||
// if hub queue is full, drop to avoid blocking
|
||||
}
|
||||
}
|
||||
if err := m.Ack(false); err != nil {
|
||||
log.Printf("error acknowledging message: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
var redisAddress = os.Getenv("REDIS_ADDRESS")
|
||||
var redisPassword = os.Getenv("REDIS_PASSWORD")
|
||||
|
||||
func main() {
|
||||
dataDir := envOrDefault("DATA_DIR", "data")
|
||||
addr := envOrDefault("ADDR", ":8080")
|
||||
amqpURL := os.Getenv("AMQP_URL")
|
||||
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: redisAddress,
|
||||
Password: redisPassword,
|
||||
DB: 0,
|
||||
app, err := backofficeadmin.New(backofficeadmin.Config{
|
||||
DataDir: envOrDefault("DATA_DIR", "data"),
|
||||
CheckoutDataDir: envOrDefault("CHECKOUT_DATA_DIR", "checkout-data"),
|
||||
RedisAddress: os.Getenv("REDIS_ADDRESS"),
|
||||
RedisPassword: os.Getenv("REDIS_PASSWORD"),
|
||||
})
|
||||
inventoryService, err := inventory.NewRedisInventoryService(rdb)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating inventory service: %v\n", err)
|
||||
log.Fatalf("Error creating backoffice: %v", err)
|
||||
}
|
||||
|
||||
_ = os.MkdirAll(dataDir, 0755)
|
||||
|
||||
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
||||
diskStorage := actor.NewDiskStorage[cart.CartGrain](dataDir, reg)
|
||||
|
||||
checkoutDataDir := envOrDefault("CHECKOUT_DATA_DIR", "checkout-data")
|
||||
_ = os.MkdirAll(checkoutDataDir, 0755)
|
||||
|
||||
regCheckout := checkout.NewCheckoutMutationRegistry(checkout.NewCheckoutMutationContext())
|
||||
diskStorageCheckout := actor.NewDiskStorage[checkout.CheckoutGrain](checkoutDataDir, regCheckout)
|
||||
|
||||
fs := NewFileServer(dataDir, checkoutDataDir, diskStorage, diskStorageCheckout)
|
||||
|
||||
hub := NewHub()
|
||||
go hub.Run()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /carts", fs.CartsHandler)
|
||||
mux.HandleFunc("GET /cart/{id}", fs.CartHandler)
|
||||
mux.HandleFunc("GET /checkouts", fs.CheckoutsHandler)
|
||||
mux.HandleFunc("GET /checkout/{id}", fs.CheckoutHandler)
|
||||
mux.HandleFunc("PUT /inventory/{locationId}/{sku}", func(w http.ResponseWriter, r *http.Request) {
|
||||
inventoryLocationId := inventory.LocationID(r.PathValue("locationId"))
|
||||
inventorySku := inventory.SKU(r.PathValue("sku"))
|
||||
pipe := rdb.Pipeline()
|
||||
var payload struct {
|
||||
Quantity int64 `json:"quantity"`
|
||||
}
|
||||
err := json.NewDecoder(r.Body).Decode(&payload)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
inventoryService.UpdateInventory(r.Context(), pipe, inventorySku, inventoryLocationId, payload.Quantity)
|
||||
|
||||
_, err = pipe.Exec(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "failed to update inventory", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
err = inventoryService.SendInventoryChanged(r.Context(), inventorySku, inventoryLocationId)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
mux.HandleFunc("/promotions", fs.PromotionsHandler)
|
||||
mux.HandleFunc("/vouchers", fs.VoucherHandler)
|
||||
mux.HandleFunc("/promotion/{id}", fs.PromotionPartHandler)
|
||||
|
||||
mux.HandleFunc("/ws", hub.ServeWS)
|
||||
app.RegisterRoutes(mux)
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
@@ -165,7 +48,7 @@ func main() {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
// Global CORS middleware allowing all origins and handling preflight
|
||||
// Global CORS middleware allowing all origins and handling preflight.
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
@@ -189,22 +72,21 @@ func main() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var conn *amqp.Connection
|
||||
if amqpURL != "" {
|
||||
conn, err := amqp.Dial(amqpURL)
|
||||
conn, err = amqp.Dial(amqpURL)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect to RabbitMQ: %v", err)
|
||||
}
|
||||
if err := startMutationConsumer(ctx, conn, hub); err != nil {
|
||||
log.Printf("AMQP listener disabled: %v", err)
|
||||
} else {
|
||||
log.Printf("AMQP listener connected")
|
||||
}
|
||||
}
|
||||
if err := app.Start(ctx, conn); err != nil {
|
||||
log.Printf("AMQP listener disabled: %v", err)
|
||||
} else if conn != nil {
|
||||
log.Printf("AMQP listener connected")
|
||||
}
|
||||
|
||||
log.Printf("backoffice HTTP listening on %s (dataDir=%s)", addr, dataDir)
|
||||
log.Printf("backoffice HTTP listening on %s", addr)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("http server error: %v", err)
|
||||
}
|
||||
|
||||
// server stopped
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user