331 lines
9.2 KiB
Go
331 lines
9.2 KiB
Go
// Command mock-woo serves minimal WooCommerce REST API v3 fixtures for local
|
|||
|
|
// live sync proofs (Test Connection, product batch push, orders/reviews pull).
|
||
|
|
//
|
||
|
|
// Usage:
|
||
|
|
//
|
||
|
|
// go run ./cmd/mock-woo -addr 127.0.0.1:19090
|
||
|
|
// go run ./cmd/mock-woo -addr 127.0.0.1:19090 -key ck_mock -secret cs_mock
|
||
|
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/json"
|
||
|
|
"flag"
|
||
|
|
"io"
|
||
|
|
"log"
|
||
|
|
"net/http"
|
||
|
|
"os"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"sync/atomic"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
const apiPrefix = "/wp-json/wc/v3"
|
||
|
|
|
||
|
|
type server struct {
|
||
|
|
key string
|
||
|
|
secret string
|
||
|
|
mu sync.Mutex
|
||
|
|
nextID atomic.Int64
|
||
|
|
bySKU map[string]product
|
||
|
|
logReq atomic.Int64
|
||
|
|
}
|
||
|
|
|
||
|
|
type product struct {
|
||
|
|
ID int `json:"id"`
|
||
|
|
SKU string `json:"sku"`
|
||
|
|
Name string `json:"name"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type orderBilling struct {
|
||
|
|
Email string `json:"email"`
|
||
|
|
FirstName string `json:"first_name"`
|
||
|
|
LastName string `json:"last_name"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type orderLine struct {
|
||
|
|
ID int `json:"id"`
|
||
|
|
Name string `json:"name"`
|
||
|
|
ProductID int `json:"product_id"`
|
||
|
|
Quantity int `json:"quantity"`
|
||
|
|
Total string `json:"total"`
|
||
|
|
SKU string `json:"sku"`
|
||
|
|
MetaData []any `json:"meta_data"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type order struct {
|
||
|
|
ID int `json:"id"`
|
||
|
|
Status string `json:"status"`
|
||
|
|
Currency string `json:"currency"`
|
||
|
|
Total string `json:"total"`
|
||
|
|
CustomerID int `json:"customer_id"`
|
||
|
|
DateCreated string `json:"date_created"`
|
||
|
|
DateCreatedGMT string `json:"date_created_gmt"`
|
||
|
|
Billing orderBilling `json:"billing"`
|
||
|
|
LineItems []orderLine `json:"line_items"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type review struct {
|
||
|
|
ID int `json:"id"`
|
||
|
|
ProductID int `json:"product_id"`
|
||
|
|
Status string `json:"status"`
|
||
|
|
Reviewer string `json:"reviewer"`
|
||
|
|
ReviewerEmail string `json:"reviewer_email"`
|
||
|
|
Review string `json:"review"`
|
||
|
|
Rating int `json:"rating"`
|
||
|
|
DateCreated string `json:"date_created"`
|
||
|
|
DateCreatedGMT string `json:"date_created_gmt"`
|
||
|
|
ProductName string `json:"product_name"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func main() {
|
||
|
|
addr := flag.String("addr", "127.0.0.1:19090", "listen address")
|
||
|
|
key := flag.String("key", envOr("MOCK_WOO_KEY", "ck_mock_local"), "consumer key")
|
||
|
|
secret := flag.String("secret", envOr("MOCK_WOO_SECRET", "cs_mock_local"), "consumer secret")
|
||
|
|
flag.Parse()
|
||
|
|
|
||
|
|
s := &server{
|
||
|
|
key: strings.TrimSpace(*key),
|
||
|
|
secret: strings.TrimSpace(*secret),
|
||
|
|
bySKU: map[string]product{},
|
||
|
|
}
|
||
|
|
s.nextID.Store(1000)
|
||
|
|
|
||
|
|
mux := http.NewServeMux()
|
||
|
|
mux.HandleFunc("/", s.handle)
|
||
|
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
_, _ = w.Write([]byte(`{"status":"ok","service":"mock-woo"}`))
|
||
|
|
})
|
||
|
|
|
||
|
|
log.Printf("mock-woo listening on http://%s key=%s", *addr, s.key)
|
||
|
|
log.Printf("WC base: http://%s%s", *addr, apiPrefix)
|
||
|
|
if err := http.ListenAndServe(*addr, mux); err != nil {
|
||
|
|
log.Fatal(err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func envOr(k, def string) string {
|
||
|
|
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
|
||
|
|
return v
|
||
|
|
}
|
||
|
|
return def
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *server) handle(w http.ResponseWriter, r *http.Request) {
|
||
|
|
s.logReq.Add(1)
|
||
|
|
if !strings.HasPrefix(r.URL.Path, apiPrefix) {
|
||
|
|
http.NotFound(w, r)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if !s.authOK(r) {
|
||
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="WooCommerce"`)
|
||
|
|
http.Error(w, `{"code":"woocommerce_rest_cannot_view","message":"unauthorized"}`, http.StatusUnauthorized)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
path := strings.TrimPrefix(r.URL.Path, apiPrefix)
|
||
|
|
path = strings.TrimSuffix(path, "/")
|
||
|
|
switch {
|
||
|
|
case r.Method == http.MethodGet && path == "/products":
|
||
|
|
s.handleListProducts(w, r)
|
||
|
|
case r.Method == http.MethodPost && path == "/products/batch":
|
||
|
|
s.handleBatchProducts(w, r)
|
||
|
|
case r.Method == http.MethodGet && path == "/products/categories":
|
||
|
|
s.writeJSON(w, []map[string]any{
|
||
|
|
{"id": 10, "name": "Demo Electronics", "slug": "demo-electronics"},
|
||
|
|
{"id": 11, "name": "Accessories", "slug": "accessories"},
|
||
|
|
})
|
||
|
|
case r.Method == http.MethodGet && path == "/products/attributes":
|
||
|
|
s.writeJSON(w, []map[string]any{
|
||
|
|
{"id": 20, "name": "Color", "slug": "pa_color"},
|
||
|
|
{"id": 21, "name": "Size", "slug": "pa_size"},
|
||
|
|
})
|
||
|
|
case r.Method == http.MethodGet && path == "/orders":
|
||
|
|
s.handleOrders(w, r)
|
||
|
|
case r.Method == http.MethodGet && path == "/products/reviews":
|
||
|
|
s.handleReviews(w, r)
|
||
|
|
default:
|
||
|
|
http.Error(w, `{"code":"rest_no_route","message":"no route"}`, http.StatusNotFound)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *server) authOK(r *http.Request) bool {
|
||
|
|
h := r.Header.Get("Authorization")
|
||
|
|
if !strings.HasPrefix(h, "Basic ") {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(h, "Basic "))
|
||
|
|
if err != nil {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
parts := strings.SplitN(string(raw), ":", 2)
|
||
|
|
if len(parts) != 2 {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
return parts[0] == s.key && parts[1] == s.secret
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *server) handleListProducts(w http.ResponseWriter, r *http.Request) {
|
||
|
|
sku := strings.TrimSpace(r.URL.Query().Get("sku"))
|
||
|
|
s.mu.Lock()
|
||
|
|
defer s.mu.Unlock()
|
||
|
|
out := make([]product, 0)
|
||
|
|
if sku != "" {
|
||
|
|
if p, ok := s.bySKU[sku]; ok {
|
||
|
|
out = append(out, p)
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
for _, p := range s.bySKU {
|
||
|
|
out = append(out, p)
|
||
|
|
if len(out) >= 1 {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
s.writeJSON(w, out)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *server) handleBatchProducts(w http.ResponseWriter, r *http.Request) {
|
||
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, 8<<20))
|
||
|
|
if err != nil {
|
||
|
|
http.Error(w, `{"message":"read body"}`, http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
var req struct {
|
||
|
|
Create []map[string]any `json:"create"`
|
||
|
|
Update []map[string]any `json:"update"`
|
||
|
|
}
|
||
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
||
|
|
http.Error(w, `{"message":"invalid json"}`, http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
s.mu.Lock()
|
||
|
|
defer s.mu.Unlock()
|
||
|
|
created := make([]product, 0, len(req.Create))
|
||
|
|
updated := make([]product, 0, len(req.Update))
|
||
|
|
for _, item := range req.Create {
|
||
|
|
p := s.upsertFromPayload(item, 0)
|
||
|
|
created = append(created, p)
|
||
|
|
}
|
||
|
|
for _, item := range req.Update {
|
||
|
|
id := intFromAny(item["id"])
|
||
|
|
p := s.upsertFromPayload(item, id)
|
||
|
|
updated = append(updated, p)
|
||
|
|
}
|
||
|
|
s.writeJSON(w, map[string]any{"create": created, "update": updated})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *server) upsertFromPayload(item map[string]any, preferID int) product {
|
||
|
|
sku, _ := item["sku"].(string)
|
||
|
|
name, _ := item["name"].(string)
|
||
|
|
if name == "" {
|
||
|
|
name = "Product"
|
||
|
|
}
|
||
|
|
id := preferID
|
||
|
|
if id <= 0 {
|
||
|
|
if existing, ok := s.bySKU[sku]; ok && sku != "" {
|
||
|
|
id = existing.ID
|
||
|
|
} else {
|
||
|
|
id = int(s.nextID.Add(1))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
p := product{ID: id, SKU: sku, Name: name}
|
||
|
|
if sku != "" {
|
||
|
|
s.bySKU[sku] = p
|
||
|
|
}
|
||
|
|
return p
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *server) handleOrders(w http.ResponseWriter, r *http.Request) {
|
||
|
|
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||
|
|
if page <= 0 {
|
||
|
|
page = 1
|
||
|
|
}
|
||
|
|
if page > 1 {
|
||
|
|
s.writeJSON(w, []order{})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
now := time.Now().UTC().Format("2006-01-02T15:04:05")
|
||
|
|
orders := []order{
|
||
|
|
{
|
||
|
|
ID: 5001, Status: "completed", Currency: "EUR", Total: "499.00", CustomerID: 1,
|
||
|
|
DateCreated: now, DateCreatedGMT: now,
|
||
|
|
Billing: orderBilling{Email: "anna.buyer@example.com", FirstName: "Anna", LastName: "Buyer"},
|
||
|
|
LineItems: []orderLine{{
|
||
|
|
ID: 1, Name: "Mock 4K TV", ProductID: 90001, Quantity: 1, Total: "499.00", SKU: "MOCK-WOO-TV",
|
||
|
|
MetaData: []any{map[string]any{"key": "categories", "value": []any{"Demo Electronics"}}},
|
||
|
|
}},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
ID: 5002, Status: "completed", Currency: "EUR", Total: "149.00", CustomerID: 2,
|
||
|
|
DateCreated: now, DateCreatedGMT: now,
|
||
|
|
Billing: orderBilling{Email: "ben.buyer@example.com", FirstName: "Ben", LastName: "Buyer"},
|
||
|
|
LineItems: []orderLine{{
|
||
|
|
ID: 2, Name: "Mock Soundbar", ProductID: 90002, Quantity: 1, Total: "149.00", SKU: "MOCK-WOO-SOUND",
|
||
|
|
MetaData: []any{map[string]any{"key": "categories", "value": []any{"Demo Electronics"}}},
|
||
|
|
}},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
ID: 5003, Status: "processing", Currency: "EUR", Total: "29.00", CustomerID: 3,
|
||
|
|
DateCreated: now, DateCreatedGMT: now,
|
||
|
|
Billing: orderBilling{Email: "cara.buyer@example.com", FirstName: "Cara", LastName: "Buyer"},
|
||
|
|
LineItems: []orderLine{{
|
||
|
|
ID: 3, Name: "Mock Cable", ProductID: 90010, Quantity: 1, Total: "29.00", SKU: "MOCK-WOO-CABLE",
|
||
|
|
MetaData: []any{map[string]any{"key": "categories", "value": []any{"Accessories"}}},
|
||
|
|
}},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
s.writeJSON(w, orders)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *server) handleReviews(w http.ResponseWriter, r *http.Request) {
|
||
|
|
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||
|
|
if page <= 0 {
|
||
|
|
page = 1
|
||
|
|
}
|
||
|
|
if page > 1 {
|
||
|
|
s.writeJSON(w, []review{})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
now := time.Now().UTC().Format("2006-01-02T15:04:05")
|
||
|
|
s.writeJSON(w, []review{
|
||
|
|
{
|
||
|
|
ID: 7001, ProductID: 90001, Status: "approved", Reviewer: "Anna Buyer",
|
||
|
|
ReviewerEmail: "anna.buyer@example.com", Review: "Great mock TV.", Rating: 5,
|
||
|
|
DateCreated: now, DateCreatedGMT: now, ProductName: "Mock 4K TV",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
ID: 7002, ProductID: 90002, Status: "approved", Reviewer: "Ben Buyer",
|
||
|
|
ReviewerEmail: "ben.buyer@example.com", Review: "Solid soundbar for demos.", Rating: 4,
|
||
|
|
DateCreated: now, DateCreatedGMT: now, ProductName: "Mock Soundbar",
|
||
|
|
},
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *server) writeJSON(w http.ResponseWriter, v any) {
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
enc := json.NewEncoder(w)
|
||
|
|
if err := enc.Encode(v); err != nil {
|
||
|
|
log.Printf("encode: %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func intFromAny(v any) int {
|
||
|
|
switch t := v.(type) {
|
||
|
|
case float64:
|
||
|
|
return int(t)
|
||
|
|
case int:
|
||
|
|
return t
|
||
|
|
case json.Number:
|
||
|
|
i, _ := t.Int64()
|
||
|
|
return int(i)
|
||
|
|
case string:
|
||
|
|
i, _ := strconv.Atoi(t)
|
||
|
|
return i
|
||
|
|
default:
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
}
|