Files
descrybe/scripts/v1-process-smoke/main.go
T

278 lines
7.9 KiB
Go
Raw Normal View History

// Command v1-process-smoke: low-concurrency legacy v1 create/process + poll.
//
// Contract (see docs/live-public-api-e2e.md):
//
// POST /api/v1/products/process { items:[{ean,…}], processing_type }
// GET /api/v1/products/process/{process_id} until COMPLETED|FAILED|CANCELLED
//
// Auth: set API_KEY (or -key). Local seed key is documented in docs/demo-user.md —
// do not commit other secrets. Default base: http://127.0.0.1:28471 (HTTP_ADDR).
//
// Usage (API + worker listening):
//
// $env:API_KEY = "<from docs/demo-user.md>"
// cd scripts/v1-process-smoke && go run .
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const (
defaultBase = "http://127.0.0.1:28471"
defaultPoll = 2 * time.Second
defaultTimeout = 10 * time.Second
defaultWait = 3 * time.Minute
defaultProcType = "full"
defaultSmokeEAN = "8700999000001"
)
type envelope struct {
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
type startData struct {
ProcessID string `json:"process_id"`
Message string `json:"message"`
TotalItems int `json:"total_items"`
ProcessedItems int `json:"processed_items"`
Errors []string `json:"errors"`
}
type statusData struct {
Status string `json:"status"`
ProcessID string `json:"process_id"`
ProcessingType json.RawMessage `json:"processing_type"`
Items []any `json:"items"`
TotalItems int `json:"total_items"`
Error string `json:"error"`
Message string `json:"message"`
}
func main() {
base := flag.String("base", envOr("API_BASE", defaultBase), "API base URL (no trailing slash)")
key := flag.String("key", strings.TrimSpace(os.Getenv("API_KEY")), "API key (Bearer); prefer env API_KEY")
eans := flag.String("eans", envOr("SMOKE_EANS", defaultSmokeEAN), "comma-separated EANs to upsert+process")
title := flag.String("title", envOr("SMOKE_TITLE", "v1-process-smoke sample"), "title set on each created item")
ptype := flag.String("processing-type", envOr("SMOKE_PROCESSING_TYPE", defaultProcType), "processing_type (full|category|title|…)")
pollEvery := flag.Duration("poll", defaultPoll, "status poll interval")
waitFor := flag.Duration("wait", defaultWait, "max time to wait for terminal status")
timeout := flag.Duration("timeout", defaultTimeout, "per-request HTTP timeout")
flag.Parse()
apiKey := strings.TrimSpace(*key)
if apiKey == "" {
fmt.Fprintln(os.Stderr, "API_KEY (or -key) is required.")
fmt.Fprintln(os.Stderr, "Local seed key: docs/demo-user.md (Platform Demo; prefix dk_demo_lo…).")
fmt.Fprintln(os.Stderr, "Example: $env:API_KEY = \"…\" ; go run .")
os.Exit(2)
}
eanList := splitCSV(*eans)
if len(eanList) == 0 {
fmt.Fprintln(os.Stderr, "-eans must list at least one EAN")
os.Exit(2)
}
if *pollEvery <= 0 || *waitFor <= 0 {
fmt.Fprintln(os.Stderr, "-poll and -wait must be > 0")
os.Exit(2)
}
baseURL := strings.TrimRight(strings.TrimSpace(*base), "/")
client := &http.Client{Timeout: *timeout}
items := make([]map[string]string, 0, len(eanList))
for _, ean := range eanList {
items = append(items, map[string]string{
"ean": ean,
"title": *title,
})
}
body, err := json.Marshal(map[string]any{
"processing_type": *ptype,
"items": items,
})
if err != nil {
fmt.Fprintf(os.Stderr, "marshal start body: %v\n", err)
os.Exit(1)
}
fmt.Printf("v1-process-smoke base=%s items=%d processing_type=%s poll=%s wait=%s\n",
baseURL, len(items), *ptype, pollEvery.String(), waitFor.String())
fmt.Println("ASSUMPTION: worker must be running or jobs stay PENDING/PROCESSING until timeout.")
startURL := baseURL + "/api/v1/products/process"
raw, code, err := doJSON(client, http.MethodPost, startURL, apiKey, body)
if err != nil {
fmt.Fprintf(os.Stderr, "POST %s: %v\n", startURL, err)
os.Exit(1)
}
start, err := decodeStart(raw, code)
if err != nil {
fmt.Fprintf(os.Stderr, "POST start failed (HTTP %d): %v\nbody=%s\n", code, err, truncate(string(raw), 400))
os.Exit(1)
}
fmt.Printf("started process_id=%s total_items=%d processed_items=%d %s\n",
start.ProcessID, start.TotalItems, start.ProcessedItems, start.Message)
if len(start.Errors) > 0 {
fmt.Printf("item warnings: %s\n", strings.Join(start.Errors, "; "))
}
deadline := time.Now().Add(*waitFor)
statusURL := baseURL + "/api/v1/products/process/" + start.ProcessID
for {
raw, code, err := doJSON(client, http.MethodGet, statusURL, apiKey, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "GET %s: %v\n", statusURL, err)
os.Exit(1)
}
st, err := decodeStatus(raw, code)
if err != nil {
fmt.Fprintf(os.Stderr, "GET status failed (HTTP %d): %v\nbody=%s\n", code, err, truncate(string(raw), 400))
os.Exit(1)
}
fmt.Printf("poll status=%s items=%d\n", st.Status, len(st.Items))
switch strings.ToUpper(st.Status) {
case "COMPLETED":
if st.Message != "" {
fmt.Println(st.Message)
}
fmt.Printf("ok process_id=%s total_items=%d\n", st.ProcessID, st.TotalItems)
os.Exit(0)
case "FAILED":
fmt.Fprintf(os.Stderr, "process failed: %s\n", firstNonEmpty(st.Error, st.Message, "unknown"))
os.Exit(1)
case "CANCELLED", "CANCELED":
fmt.Fprintln(os.Stderr, "process cancelled")
os.Exit(1)
}
if time.Now().After(deadline) {
fmt.Fprintf(os.Stderr, "timeout after %s (last status=%s). Is the worker running?\n", waitFor.String(), st.Status)
os.Exit(1)
}
time.Sleep(*pollEvery)
}
}
func doJSON(client *http.Client, method, url, apiKey string, body []byte) ([]byte, int, error) {
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, err := http.NewRequest(method, url, rdr)
if err != nil {
return nil, 0, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer res.Body.Close()
raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return nil, res.StatusCode, err
}
return raw, res.StatusCode, nil
}
func decodeStart(raw []byte, code int) (startData, error) {
var out startData
data, err := unwrapData(raw, code)
if err != nil {
return out, err
}
if err := json.Unmarshal(data, &out); err != nil {
return out, err
}
if strings.TrimSpace(out.ProcessID) == "" {
return out, fmt.Errorf("missing data.process_id")
}
return out, nil
}
func decodeStatus(raw []byte, code int) (statusData, error) {
var out statusData
data, err := unwrapData(raw, code)
if err != nil {
return out, err
}
if err := json.Unmarshal(data, &out); err != nil {
return out, err
}
if strings.TrimSpace(out.Status) == "" {
return out, fmt.Errorf("missing data.status")
}
return out, nil
}
func unwrapData(raw []byte, code int) (json.RawMessage, error) {
var env envelope
if err := json.Unmarshal(raw, &env); err != nil {
return nil, fmt.Errorf("invalid JSON: %w", err)
}
if env.Error != nil {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
if code < 200 || code >= 300 {
return nil, fmt.Errorf("unexpected HTTP %d", code)
}
if len(env.Data) == 0 || string(env.Data) == "null" {
return nil, fmt.Errorf("missing data")
}
return env.Data, nil
}
func splitCSV(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func envOr(key, fallback string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return fallback
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}