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,592 @@
|
||||
package shopify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAPIVersion = "2024-10"
|
||||
defaultTimeout = 30 * time.Second
|
||||
maxBodyBytes = 8 << 20
|
||||
dryRunToken = "dry-run"
|
||||
maxRateLimitRetries = 5
|
||||
maxSKULookupChunk = 50
|
||||
)
|
||||
|
||||
var apiVersionRe = regexp.MustCompile(`^\d{4}-\d{2}$`)
|
||||
|
||||
// normalizeAPIVersion accepts Shopify Admin API versions like "2024-10".
|
||||
// Anything else falls back to the default to prevent path injection.
|
||||
func normalizeAPIVersion(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if apiVersionRe.MatchString(v) {
|
||||
return v
|
||||
}
|
||||
return defaultAPIVersion
|
||||
}
|
||||
|
||||
// shopifySKUSearchQuery builds a GraphQL productVariants search string.
|
||||
// Quoting prevents SKU characters from acting as Shopify search operators.
|
||||
func shopifySKUSearchQuery(sku string) string {
|
||||
escaped := strings.ReplaceAll(sku, `\`, `\\`)
|
||||
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
|
||||
return `sku:"` + escaped + `"`
|
||||
}
|
||||
|
||||
// shopifySKUSearchQueryOR joins multiple sku:"…" clauses for one GraphQL lookup.
|
||||
func shopifySKUSearchQueryOR(skus []string) string {
|
||||
parts := make([]string, 0, len(skus))
|
||||
for _, sku := range skus {
|
||||
sku = strings.TrimSpace(sku)
|
||||
if sku == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, shopifySKUSearchQuery(sku))
|
||||
}
|
||||
return strings.Join(parts, " OR ")
|
||||
}
|
||||
|
||||
func uniqueTrimmedSKUs(skus []string) []string {
|
||||
seen := make(map[string]struct{}, len(skus))
|
||||
out := make([]string, 0, len(skus))
|
||||
for _, sku := range skus {
|
||||
sku = strings.TrimSpace(sku)
|
||||
if sku == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[sku]; ok {
|
||||
continue
|
||||
}
|
||||
seen[sku] = struct{}{}
|
||||
out = append(out, sku)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func retryAfterWait(h http.Header, attempt int) time.Duration {
|
||||
if ra := strings.TrimSpace(h.Get("Retry-After")); ra != "" {
|
||||
if secs, err := strconv.Atoi(ra); err == nil && secs >= 0 {
|
||||
return time.Duration(secs) * time.Second
|
||||
}
|
||||
}
|
||||
// 1s, 2s, 4s, 8s, 16s (capped)
|
||||
shift := attempt
|
||||
if shift > 4 {
|
||||
shift = 4
|
||||
}
|
||||
return time.Duration(1<<shift) * time.Second
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
ShopDomain string
|
||||
AccessToken string
|
||||
APIVersion string
|
||||
HTTP *http.Client
|
||||
DryRun bool
|
||||
|
||||
dryProductSeq atomic.Int64
|
||||
dryOrderSeq atomic.Int64
|
||||
}
|
||||
|
||||
type ShopInfo struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Domain string `json:"domain"`
|
||||
Email string `json:"email"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
type ProductImage struct {
|
||||
Src string `json:"src,omitempty"`
|
||||
}
|
||||
|
||||
type ProductVariant struct {
|
||||
ID int64 `json:"id,omitempty"`
|
||||
SKU string `json:"sku,omitempty"`
|
||||
Price string `json:"price,omitempty"`
|
||||
Barcode string `json:"barcode,omitempty"`
|
||||
}
|
||||
|
||||
type Metafield struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type ProductPayload struct {
|
||||
ID int64 `json:"id,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
BodyHTML string `json:"body_html,omitempty"`
|
||||
Vendor string `json:"vendor,omitempty"`
|
||||
ProductType string `json:"product_type,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Tags string `json:"tags,omitempty"`
|
||||
Variants []ProductVariant `json:"variants,omitempty"`
|
||||
Images []ProductImage `json:"images,omitempty"`
|
||||
Metafields []Metafield `json:"metafields,omitempty"`
|
||||
}
|
||||
|
||||
type Product struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Variants []ProductVariant `json:"variants"`
|
||||
}
|
||||
|
||||
type OrderCustomer struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
}
|
||||
|
||||
type OrderLineItem struct {
|
||||
ID int64 `json:"id"`
|
||||
ProductID int64 `json:"product_id"`
|
||||
VariantID int64 `json:"variant_id"`
|
||||
SKU string `json:"sku"`
|
||||
Title string `json:"title"`
|
||||
Name string `json:"name"`
|
||||
Quantity int `json:"quantity"`
|
||||
Price string `json:"price"`
|
||||
Raw json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
type Order struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
FinancialStatus string `json:"financial_status"`
|
||||
FulfillmentStatus string `json:"fulfillment_status"`
|
||||
Currency string `json:"currency"`
|
||||
TotalPrice string `json:"total_price"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Customer *OrderCustomer `json:"customer"`
|
||||
Email string `json:"email"`
|
||||
LineItems []OrderLineItem `json:"line_items"`
|
||||
Raw json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
func NewClient(shopDomain, token, apiVersion string, httpClient *http.Client) *Client {
|
||||
if httpClient == nil {
|
||||
httpClient = security.SafeHTTPClient(defaultTimeout, false)
|
||||
}
|
||||
apiVersion = normalizeAPIVersion(apiVersion)
|
||||
token = strings.TrimSpace(token)
|
||||
dry := strings.EqualFold(token, dryRunToken)
|
||||
return &Client{
|
||||
ShopDomain: strings.TrimSpace(strings.ToLower(shopDomain)),
|
||||
AccessToken: token,
|
||||
APIVersion: apiVersion,
|
||||
HTTP: httpClient,
|
||||
DryRun: dry,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) TestConnection(ctx context.Context) (*ShopInfo, error) {
|
||||
if c.DryRun {
|
||||
return &ShopInfo{ID: 1, Name: "Dry Run Shop", Domain: c.ShopDomain, Currency: "USD"}, nil
|
||||
}
|
||||
raw, err := c.do(ctx, http.MethodGet, "/shop.json", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var wrap struct {
|
||||
Shop ShopInfo `json:"shop"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wrap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &wrap.Shop, nil
|
||||
}
|
||||
|
||||
func (c *Client) FindProductBySKU(ctx context.Context, sku string) (*Product, error) {
|
||||
found, err := c.FindProductsBySKUs(ctx, []string{sku})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sku = strings.TrimSpace(sku)
|
||||
if p, ok := found[sku]; ok && p.ID > 0 {
|
||||
cp := p
|
||||
return &cp, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// FindProductsBySKUs resolves many SKUs in chunked GraphQL lookups (not one HTTP call per SKU).
|
||||
// Keys in the returned map are the exact requested SKUs that matched.
|
||||
func (c *Client) FindProductsBySKUs(ctx context.Context, skus []string) (map[string]Product, error) {
|
||||
skus = uniqueTrimmedSKUs(skus)
|
||||
out := make(map[string]Product, len(skus))
|
||||
if len(skus) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
if c.DryRun {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type gqlResp struct {
|
||||
Data struct {
|
||||
ProductVariants struct {
|
||||
Edges []struct {
|
||||
Node struct {
|
||||
SKU string `json:"sku"`
|
||||
Product struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
} `json:"product"`
|
||||
} `json:"node"`
|
||||
} `json:"edges"`
|
||||
} `json:"productVariants"`
|
||||
} `json:"data"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
|
||||
chunkSize := maxSKULookupChunk
|
||||
for i := 0; i < len(skus); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(skus) {
|
||||
end = len(skus)
|
||||
}
|
||||
chunk := skus[i:end]
|
||||
want := make(map[string]struct{}, len(chunk))
|
||||
for _, sku := range chunk {
|
||||
want[sku] = struct{}{}
|
||||
}
|
||||
body := map[string]any{
|
||||
"query": `query($q: String!, $n: Int!) {
|
||||
productVariants(first: $n, query: $q) {
|
||||
edges { node { sku product { id title } } }
|
||||
}
|
||||
}`,
|
||||
"variables": map[string]any{
|
||||
"q": shopifySKUSearchQueryOR(chunk),
|
||||
"n": len(chunk),
|
||||
},
|
||||
}
|
||||
raw, err := c.doGraphQL(ctx, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp gqlResp
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp.Errors) > 0 {
|
||||
return nil, fmt.Errorf("shopify graphql: %s", resp.Errors[0].Message)
|
||||
}
|
||||
for _, edge := range resp.Data.ProductVariants.Edges {
|
||||
node := edge.Node
|
||||
sku := strings.TrimSpace(node.SKU)
|
||||
if _, ok := want[sku]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, exists := out[sku]; exists {
|
||||
continue
|
||||
}
|
||||
id, err := parseShopifyGID(node.Product.ID)
|
||||
if err != nil || id <= 0 {
|
||||
continue
|
||||
}
|
||||
out[sku] = Product{
|
||||
ID: id,
|
||||
Title: node.Product.Title,
|
||||
Variants: []ProductVariant{{SKU: sku}},
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseShopifyGID(gid string) (int64, error) {
|
||||
// gid://shopify/Product/1234567890
|
||||
parts := strings.Split(strings.TrimSpace(gid), "/")
|
||||
if len(parts) == 0 {
|
||||
return 0, fmt.Errorf("empty gid")
|
||||
}
|
||||
return strconv.ParseInt(parts[len(parts)-1], 10, 64)
|
||||
}
|
||||
|
||||
func (c *Client) doGraphQL(ctx context.Context, body any) ([]byte, error) {
|
||||
base := AdminBaseURL(c.ShopDomain)
|
||||
apiPath := fmt.Sprintf("/admin/api/%s/graphql.json", c.APIVersion)
|
||||
u, err := url.Parse(base + apiPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := GuardAdminURL(u.String(), c.ShopDomain); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= maxRateLimitRetries; attempt++ {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Descrybe-Shopify/2.0")
|
||||
req.Header.Set("X-Shopify-Access-Token", c.AccessToken)
|
||||
res, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limited := io.LimitReader(res.Body, maxBodyBytes+1)
|
||||
raw, err := io.ReadAll(limited)
|
||||
res.Body.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) > maxBodyBytes {
|
||||
return nil, fmt.Errorf("shopify response too large")
|
||||
}
|
||||
if res.StatusCode == http.StatusTooManyRequests || res.StatusCode == http.StatusServiceUnavailable {
|
||||
lastErr = fmt.Errorf("shopify graphql %d: rate limited", res.StatusCode)
|
||||
if attempt == maxRateLimitRetries {
|
||||
break
|
||||
}
|
||||
wait := retryAfterWait(res.Header, attempt)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
continue
|
||||
}
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
msg := strings.TrimSpace(string(raw))
|
||||
if len(msg) > 400 {
|
||||
msg = msg[:400] + "…"
|
||||
}
|
||||
return nil, fmt.Errorf("shopify graphql %d: %s", res.StatusCode, msg)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (c *Client) CreateProduct(ctx context.Context, product ProductPayload) (*Product, error) {
|
||||
if c.DryRun {
|
||||
id := c.dryProductSeq.Add(1) + 1000
|
||||
return &Product{ID: id, Title: product.Title, Variants: product.Variants}, nil
|
||||
}
|
||||
raw, err := c.do(ctx, http.MethodPost, "/products.json", nil, map[string]any{"product": product})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var wrap struct {
|
||||
Product Product `json:"product"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wrap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &wrap.Product, nil
|
||||
}
|
||||
|
||||
func (c *Client) UpdateProduct(ctx context.Context, productID int64, product ProductPayload) (*Product, error) {
|
||||
product.ID = productID
|
||||
if c.DryRun {
|
||||
return &Product{ID: productID, Title: product.Title, Variants: product.Variants}, nil
|
||||
}
|
||||
path := fmt.Sprintf("/products/%d.json", productID)
|
||||
raw, err := c.do(ctx, http.MethodPut, path, nil, map[string]any{"product": product})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var wrap struct {
|
||||
Product Product `json:"product"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wrap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &wrap.Product, nil
|
||||
}
|
||||
|
||||
func (c *Client) ListOrdersPage(ctx context.Context, pageInfo string, limit int) ([]Order, string, error) {
|
||||
if limit <= 0 || limit > 250 {
|
||||
limit = 50
|
||||
}
|
||||
if c.DryRun {
|
||||
if pageInfo != "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
id := c.dryOrderSeq.Add(1) + 5000
|
||||
return []Order{{
|
||||
ID: id,
|
||||
Name: fmt.Sprintf("#D%d", id),
|
||||
FinancialStatus: "paid",
|
||||
Currency: "USD",
|
||||
TotalPrice: "19.99",
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Email: "dry-run@example.com",
|
||||
Customer: &OrderCustomer{ID: 1, Email: "dry-run@example.com", FirstName: "Dry", LastName: "Run"},
|
||||
LineItems: []OrderLineItem{{
|
||||
ID: 1, ProductID: 1001, VariantID: 2001, SKU: "DRY-SKU", Title: "Dry product", Name: "Dry product", Quantity: 1, Price: "19.99",
|
||||
}},
|
||||
}}, "", nil
|
||||
}
|
||||
q := map[string]string{
|
||||
"limit": strconv.Itoa(limit),
|
||||
"status": "any",
|
||||
"order": "created_at desc",
|
||||
}
|
||||
if pageInfo != "" {
|
||||
q["page_info"] = pageInfo
|
||||
}
|
||||
raw, linkHeader, err := c.doWithHeaders(ctx, http.MethodGet, "/orders.json", q, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
var wrap struct {
|
||||
Orders []Order `json:"orders"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wrap); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
var rawItems []json.RawMessage
|
||||
_ = json.Unmarshal(extractArray(raw, "orders"), &rawItems)
|
||||
for i := range wrap.Orders {
|
||||
if i < len(rawItems) {
|
||||
wrap.Orders[i].Raw = rawItems[i]
|
||||
}
|
||||
}
|
||||
return wrap.Orders, nextPageInfo(linkHeader), nil
|
||||
}
|
||||
|
||||
func extractArray(raw []byte, key string) []byte {
|
||||
var m map[string]json.RawMessage
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
return nil
|
||||
}
|
||||
return m[key]
|
||||
}
|
||||
|
||||
func nextPageInfo(linkHeader string) string {
|
||||
// Rel="next" URL page_info=...
|
||||
parts := strings.Split(linkHeader, ",")
|
||||
for _, part := range parts {
|
||||
if !strings.Contains(part, `rel="next"`) {
|
||||
continue
|
||||
}
|
||||
start := strings.Index(part, "<")
|
||||
end := strings.Index(part, ">")
|
||||
if start < 0 || end <= start {
|
||||
continue
|
||||
}
|
||||
u, err := url.Parse(part[start+1 : end])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
return u.Query().Get("page_info")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *Client) do(ctx context.Context, method, path string, query map[string]string, body any) ([]byte, error) {
|
||||
raw, _, err := c.doWithHeaders(ctx, method, path, query, body)
|
||||
return raw, err
|
||||
}
|
||||
|
||||
func (c *Client) doWithHeaders(ctx context.Context, method, path string, query map[string]string, body any) ([]byte, string, error) {
|
||||
base := AdminBaseURL(c.ShopDomain)
|
||||
apiPath := fmt.Sprintf("/admin/api/%s%s", c.APIVersion, path)
|
||||
u, err := url.Parse(base + apiPath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := GuardAdminURL(u.String(), c.ShopDomain); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
q := u.Query()
|
||||
for k, v := range query {
|
||||
if v != "" {
|
||||
q.Set(k, v)
|
||||
}
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
if err := GuardAdminURL(u.String(), c.ShopDomain); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
var bodyBytes []byte
|
||||
if body != nil {
|
||||
bodyBytes, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= maxRateLimitRetries; attempt++ {
|
||||
var rdr io.Reader
|
||||
if bodyBytes != nil {
|
||||
rdr = bytes.NewReader(bodyBytes)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, u.String(), rdr)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "Descrybe-Shopify/2.0")
|
||||
req.Header.Set("X-Shopify-Access-Token", c.AccessToken)
|
||||
if bodyBytes != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
res, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
limited := io.LimitReader(res.Body, maxBodyBytes+1)
|
||||
raw, err := io.ReadAll(limited)
|
||||
link := res.Header.Get("Link")
|
||||
res.Body.Close()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(raw) > maxBodyBytes {
|
||||
return nil, "", fmt.Errorf("shopify response too large")
|
||||
}
|
||||
if res.StatusCode == http.StatusTooManyRequests || res.StatusCode == http.StatusServiceUnavailable {
|
||||
lastErr = fmt.Errorf("shopify api %d: rate limited", res.StatusCode)
|
||||
if attempt == maxRateLimitRetries {
|
||||
break
|
||||
}
|
||||
wait := retryAfterWait(res.Header, attempt)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, "", ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
continue
|
||||
}
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
msg := strings.TrimSpace(string(raw))
|
||||
if len(msg) > 400 {
|
||||
msg = msg[:400] + "…"
|
||||
}
|
||||
return nil, "", fmt.Errorf("shopify api %d: %s", res.StatusCode, msg)
|
||||
}
|
||||
return raw, link, nil
|
||||
}
|
||||
return nil, "", lastErr
|
||||
}
|
||||
Reference in New Issue
Block a user