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< 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 }