package woocommerce import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "github.com/descrybe/descrybe-v2/apps/api/internal/security" ) const ( apiPrefix = "/wp-json/wc/v3" defaultTimeout = 30 * time.Second maxBodyBytes = 8 << 20 maxRateLimitRetries = 5 maxSKULookupChunk = 50 ) type Client struct { BaseURL string ConsumerKey string ConsumerSecret string HTTP *http.Client } type ProductPayload struct { ID int `json:"id,omitempty"` Name string `json:"name,omitempty"` Type string `json:"type,omitempty"` Status string `json:"status,omitempty"` Description string `json:"description,omitempty"` ShortDescription string `json:"short_description,omitempty"` SKU string `json:"sku,omitempty"` RegularPrice string `json:"regular_price,omitempty"` Categories []map[string]any `json:"categories,omitempty"` Attributes []map[string]any `json:"attributes,omitempty"` Images []map[string]string `json:"images,omitempty"` MetaData []MetaDatum `json:"meta_data,omitempty"` ManageStock *bool `json:"manage_stock,omitempty"` StockStatus string `json:"stock_status,omitempty"` } type MetaDatum struct { Key string `json:"key"` Value string `json:"value"` } type Product struct { ID int `json:"id"` SKU string `json:"sku"` Name string `json:"name"` } type BatchRequest struct { Create []ProductPayload `json:"create,omitempty"` Update []ProductPayload `json:"update,omitempty"` } type BatchResponse struct { Create []Product `json:"create"` Update []Product `json:"update"` } type Category struct { ID int `json:"id"` Name string `json:"name"` Slug string `json:"slug"` } type Attribute struct { ID int `json:"id"` Name string `json:"name"` Slug string `json:"slug"` } func NewClient(baseURL, key, secret string, httpClient *http.Client) *Client { if httpClient == nil { httpClient = security.SafeHTTPClient(defaultTimeout, true) } return &Client{ BaseURL: strings.TrimRight(baseURL, "/"), ConsumerKey: key, ConsumerSecret: secret, HTTP: httpClient, } } func (c *Client) TestConnection(ctx context.Context) error { _, err := c.do(ctx, http.MethodGet, "/products", map[string]string{"per_page": "1"}, nil) return err } func (c *Client) ListProductsBySKU(ctx context.Context, sku string) ([]Product, error) { found, err := c.ListProductsBySKUs(ctx, []string{sku}) if err != nil { return nil, err } sku = strings.TrimSpace(sku) if p, ok := found[sku]; ok { return []Product{p}, nil } return nil, nil } // ListProductsBySKUs resolves many SKUs via comma-separated WC filters (chunked). // Keys in the returned map are the exact requested SKUs that matched. func (c *Client) ListProductsBySKUs(ctx context.Context, skus []string) (map[string]Product, error) { seen := make(map[string]struct{}, len(skus)) clean := 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{}{} clean = append(clean, sku) } out := make(map[string]Product, len(clean)) if len(clean) == 0 { return out, nil } chunkSize := maxSKULookupChunk for i := 0; i < len(clean); i += chunkSize { end := i + chunkSize if end > len(clean) { end = len(clean) } chunk := clean[i:end] want := make(map[string]struct{}, len(chunk)) for _, sku := range chunk { want[sku] = struct{}{} } perPage := len(chunk) if perPage < 10 { perPage = 10 } if perPage > 100 { perPage = 100 } raw, err := c.do(ctx, http.MethodGet, "/products", map[string]string{ "sku": strings.Join(chunk, ","), "per_page": strconv.Itoa(perPage), }, nil) if err != nil { return nil, err } var products []Product if err := json.Unmarshal(raw, &products); err != nil { return nil, err } for _, p := range products { sku := strings.TrimSpace(p.SKU) if _, ok := want[sku]; !ok { continue } if _, exists := out[sku]; exists { continue } out[sku] = p } } return out, nil } func (c *Client) BatchProducts(ctx context.Context, req BatchRequest) (BatchResponse, error) { raw, err := c.do(ctx, http.MethodPost, "/products/batch", nil, req) if err != nil { return BatchResponse{}, err } var out BatchResponse if err := json.Unmarshal(raw, &out); err != nil { return BatchResponse{}, err } return out, nil } func (c *Client) ListCategories(ctx context.Context) ([]Category, error) { raw, err := c.do(ctx, http.MethodGet, "/products/categories", map[string]string{"per_page": "100"}, nil) if err != nil { return nil, err } var out []Category if err := json.Unmarshal(raw, &out); err != nil { return nil, err } return out, nil } func (c *Client) ListAttributes(ctx context.Context) ([]Attribute, error) { raw, err := c.do(ctx, http.MethodGet, "/products/attributes", map[string]string{"per_page": "100"}, nil) if err != nil { return nil, err } var out []Attribute if err := json.Unmarshal(raw, &out); err != nil { return nil, err } return out, nil } 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 } } shift := attempt if shift > 4 { shift = 4 } return time.Duration(1< maxBodyBytes { return nil, fmt.Errorf("woocommerce response too large") } if res.StatusCode == http.StatusTooManyRequests || res.StatusCode == http.StatusServiceUnavailable { lastErr = fmt.Errorf("woocommerce api %s: rate limited", strconv.Itoa(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("woocommerce api %s: %s", strconv.Itoa(res.StatusCode), msg) } return raw, nil } return nil, lastErr }