Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
// 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] + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user