300 lines
7.6 KiB
Go
300 lines
7.6 KiB
Go
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<<shift) * time.Second
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *Client) do(ctx context.Context, method, path string, query map[string]string, body any) ([]byte, error) {
|
||
|
|
u, err := url.Parse(c.BaseURL + apiPrefix + path)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
q := u.Query()
|
||
|
|
for k, v := range query {
|
||
|
|
q.Set(k, v)
|
||
|
|
}
|
||
|
|
u.RawQuery = q.Encode()
|
||
|
|
|
||
|
|
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-WooCommerce/2.0")
|
||
|
|
if bodyBytes != nil {
|
||
|
|
req.Header.Set("Content-Type", "application/json")
|
||
|
|
}
|
||
|
|
token := base64.StdEncoding.EncodeToString([]byte(c.ConsumerKey + ":" + c.ConsumerSecret))
|
||
|
|
req.Header.Set("Authorization", "Basic "+token)
|
||
|
|
|
||
|
|
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("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
|
||
|
|
}
|