Compare commits

..

No commits in common. "master" and "v2.1.18" have entirely different histories.

8 changed files with 124 additions and 894 deletions

500
client.go
View file

@ -17,6 +17,10 @@ import (
"github.com/google/go-querystring/query" "github.com/google/go-querystring/query"
) )
// HTTPStatusUnknown can return for the method `/api/v5/customers/upload`, `/api/v5/customers-corporate/upload`,
// `/api/v5/orders/upload`.
const HTTPStatusUnknown = 460
// New initialize client. // New initialize client.
func New(url string, key string) *Client { func New(url string, key string) *Client {
return &Client{ return &Client{
@ -32,125 +36,6 @@ func (c *Client) WithLogger(logger BasicLogger) *Client {
return c return c
} }
// EnableRateLimiter activates rate limiting with specified retry attempts.
func (c *Client) EnableRateLimiter(maxAttempts uint) *Client {
c.mutex.Lock()
defer c.mutex.Unlock()
c.limiter = &RateLimiter{
maxAttempts: maxAttempts,
lastRequest: time.Now().Add(-time.Second), // Initialize to allow immediate first request.
}
return c
}
// applyRateLimit applies rate limiting before sending a request.
func (c *Client) applyRateLimit(uri string) {
if c.limiter == nil {
return
}
c.limiter.mutex.Lock()
defer c.limiter.mutex.Unlock()
var delay time.Duration
if strings.HasPrefix(uri, "/telephony") {
delay = telephonyDelay
} else {
delay = regularDelay
}
elapsed := time.Since(c.limiter.lastRequest)
if elapsed < delay {
time.Sleep(delay - elapsed)
}
c.limiter.lastRequest = time.Now()
}
func (c *Client) executeWithRetryBytes(
uri string,
executeFunc func() (interface{}, int, error),
) ([]byte, int, error) {
res, status, err := c.executeWithRetry(uri, executeFunc)
if res == nil {
return nil, status, err
}
return res.([]byte), status, err
}
func (c *Client) executeWithRetryReadCloser(
uri string,
executeFunc func() (interface{}, int, error),
) (io.ReadCloser, int, error) {
res, status, err := c.executeWithRetry(uri, executeFunc)
if res == nil {
return nil, status, err
}
return res.(io.ReadCloser), status, err
}
// executeWithRetry executes a request with retry logic for rate limiting.
func (c *Client) executeWithRetry(
uri string,
executeFunc func() (interface{}, int, error),
) (interface{}, int, error) {
if c.limiter == nil {
return executeFunc()
}
var (
res interface{}
statusCode int
err error
lastAttempt bool
attempt uint = 1
maxAttempts = c.limiter.maxAttempts
totalAttempts = "∞"
infinite = maxAttempts == 0
)
var baseDelay time.Duration
if strings.HasPrefix(uri, "/telephony") {
baseDelay = telephonyDelay
} else {
baseDelay = regularDelay
}
if !infinite {
totalAttempts = strconv.FormatUint(uint64(maxAttempts), 10)
}
for infinite || attempt <= maxAttempts {
c.applyRateLimit(uri)
res, statusCode, err = executeFunc()
lastAttempt = !infinite && attempt == maxAttempts
// If rate limited on final attempt, set error to ErrRateLimited. Return results otherwise.
if statusCode == http.StatusServiceUnavailable && lastAttempt {
return res, statusCode, ErrRateLimited
}
// If not rate limited or on final attempt, return result.
if statusCode != http.StatusServiceUnavailable || lastAttempt {
return res, statusCode, err
}
// Calculate exponential backoff delay: baseDelay * 2^(attempt-1).
backoffDelay := baseDelay * (1 << (attempt - 1))
if c.Debug {
c.writeLog("API Error: rate limited (503), retrying in %v (attempt %d/%s)",
backoffDelay, attempt, totalAttempts)
}
time.Sleep(backoffDelay)
attempt++
}
return res, statusCode, err
}
// writeLog writes to the log. // writeLog writes to the log.
func (c *Client) writeLog(format string, v ...interface{}) { func (c *Client) writeLog(format string, v ...interface{}) {
if c.logger != nil { if c.logger != nil {
@ -163,6 +48,7 @@ func (c *Client) writeLog(format string, v ...interface{}) {
// GetRequest implements GET Request. // GetRequest implements GET Request.
func (c *Client) GetRequest(urlWithParameters string, versioned ...bool) ([]byte, int, error) { func (c *Client) GetRequest(urlWithParameters string, versioned ...bool) ([]byte, int, error) {
var res []byte
var prefix = "/api/v5" var prefix = "/api/v5"
if len(versioned) > 0 { if len(versioned) > 0 {
@ -171,49 +57,41 @@ func (c *Client) GetRequest(urlWithParameters string, versioned ...bool) ([]byte
} }
} }
uri := urlWithParameters req, err := http.NewRequest("GET", fmt.Sprintf("%s%s%s", c.URL, prefix, urlWithParameters), nil)
if err != nil {
return res, 0, err
}
return c.executeWithRetryBytes(uri, func() (interface{}, int, error) { req.Header.Set("X-API-KEY", c.Key)
var res []byte
req, err := http.NewRequest("GET", fmt.Sprintf("%s%s%s", c.URL, prefix, urlWithParameters), nil) if c.Debug {
if err != nil { c.writeLog("API Request: %s %s", fmt.Sprintf("%s%s%s", c.URL, prefix, urlWithParameters), c.Key)
return res, 0, err }
}
req.Header.Set("X-API-KEY", c.Key) resp, err := c.httpClient.Do(req)
if err != nil {
return res, 0, err
}
if c.Debug { if resp.StatusCode >= http.StatusInternalServerError {
c.writeLog("API Request: %s %s", fmt.Sprintf("%s%s%s", c.URL, prefix, urlWithParameters), c.Key) return res, resp.StatusCode, CreateGenericAPIError(
} fmt.Sprintf("HTTP request error. Status code: %d.", resp.StatusCode))
}
resp, err := c.httpClient.Do(req) res, err = buildRawResponse(resp)
if err != nil { if err != nil {
return res, 0, err return res, 0, err
} }
if resp.StatusCode >= http.StatusInternalServerError && resp.StatusCode != http.StatusServiceUnavailable { if resp.StatusCode >= http.StatusBadRequest && resp.StatusCode < http.StatusInternalServerError {
return res, resp.StatusCode, CreateGenericAPIError( return res, resp.StatusCode, CreateAPIError(res)
fmt.Sprintf("HTTP request error. Status code: %d.", resp.StatusCode)) }
}
res, err = buildRawResponse(resp) if c.Debug {
if err != nil { c.writeLog("API Response: %s", res)
return res, 0, err }
}
if resp.StatusCode >= http.StatusBadRequest && return res, resp.StatusCode, nil
resp.StatusCode < http.StatusInternalServerError &&
resp.StatusCode != http.StatusServiceUnavailable {
return res, resp.StatusCode, CreateAPIError(res)
}
if c.Debug {
c.writeLog("API Response: %s", res)
}
return res, resp.StatusCode, nil
})
} }
// PostRequest implements POST Request with generic body data. // PostRequest implements POST Request with generic body data.
@ -222,7 +100,12 @@ func (c *Client) PostRequest(
postData interface{}, postData interface{},
contType ...string, contType ...string,
) ([]byte, int, error) { ) ([]byte, int, error) {
var contentType string var (
res []byte
contentType string
)
prefix := "/api/v5"
if len(contType) > 0 { if len(contType) > 0 {
contentType = contType[0] contentType = contType[0]
@ -230,55 +113,47 @@ func (c *Client) PostRequest(
contentType = "application/x-www-form-urlencoded" contentType = "application/x-www-form-urlencoded"
} }
prefix := "/api/v5" reader, err := getReaderForPostData(postData)
if err != nil {
return res, 0, err
}
return c.executeWithRetryBytes(uri, func() (interface{}, int, error) { req, err := http.NewRequest("POST", fmt.Sprintf("%s%s%s", c.URL, prefix, uri), reader)
var res []byte if err != nil {
return res, 0, err
}
reader, err := getReaderForPostData(postData) req.Header.Set("Content-Type", contentType)
if err != nil { req.Header.Set("X-API-KEY", c.Key)
return res, 0, err
}
req, err := http.NewRequest("POST", fmt.Sprintf("%s%s%s", c.URL, prefix, uri), reader) if c.Debug {
if err != nil { c.writeLog("API Request: %s %s", uri, c.Key)
return res, 0, err }
}
req.Header.Set("Content-Type", contentType) resp, err := c.httpClient.Do(req)
req.Header.Set("X-API-KEY", c.Key) if err != nil {
return res, 0, err
}
if c.Debug { if resp.StatusCode >= http.StatusInternalServerError {
c.writeLog("API Request: %s %s", uri, c.Key) return res, resp.StatusCode, CreateGenericAPIError(
} fmt.Sprintf("HTTP request error. Status code: %d.", resp.StatusCode))
}
resp, err := c.httpClient.Do(req) res, err = buildRawResponse(resp)
if err != nil { if err != nil {
return res, 0, err return res, 0, err
} }
if resp.StatusCode >= http.StatusInternalServerError && resp.StatusCode != http.StatusServiceUnavailable { if resp.StatusCode >= http.StatusBadRequest && resp.StatusCode < http.StatusInternalServerError {
return res, resp.StatusCode, CreateGenericAPIError( return res, resp.StatusCode, CreateAPIError(res)
fmt.Sprintf("HTTP request error. Status code: %d.", resp.StatusCode)) }
}
res, err = buildRawResponse(resp) if c.Debug {
if err != nil { c.writeLog("API Response: %s", res)
return res, 0, err }
}
if resp.StatusCode >= http.StatusBadRequest && return res, resp.StatusCode, nil
resp.StatusCode < http.StatusInternalServerError &&
resp.StatusCode != http.StatusServiceUnavailable {
return res, resp.StatusCode, CreateAPIError(res)
}
if c.Debug {
c.writeLog("API Response: %s", res)
}
return res, resp.StatusCode, nil
})
} }
func getReaderForPostData(postData interface{}) (io.Reader, error) { func getReaderForPostData(postData interface{}) (io.Reader, error) {
@ -1957,174 +1832,6 @@ func (c *Client) CorporateCustomerEdit(customer CorporateCustomer, by string, si
return resp, status, nil return resp, status, nil
} }
// ClearCart clears the current customer's shopping cart
//
// For more information see https://docs.retailcrm.ru/Developers/API/APIVersions/APIv5#post--api-v5-customer-interaction-site-cart-clear
//
// Example:
//
// var client = retailcrm.New("https://demo.url", "09jIJ")
//
// data, status, err := client.ClearCart("site_id", retailcrm.SiteFilter{SiteBy: "id"},
// retailcrm.ClearCartRequest{
// ClearedAt: time.Now().String(),
// Customer: retailcrm.CartCustomer{
// ID: 1,
// ExternalID: "ext_id",
// Site: "site",
// BrowserID: "browser_id",
// GaClientID: "ga_client_id",
// },
// Order: retailcrm.ClearCartOrder{
// ID: 1,
// ExternalID: "ext_id",
// Number: "abc123",
// },
// },
// )
//
// if err != nil {
// if apiErr, ok := retailcrm.AsAPIError(err); ok {
// log.Fatalf("http status: %d, %s", status, apiErr.String())
// }
//
// log.Fatalf("http status: %d, error: %s", status, err)
// }
func (c *Client) ClearCart(site string, filter SiteFilter, req ClearCartRequest) (
SuccessfulResponse, int, error,
) {
var resp SuccessfulResponse
updateJSON, err := json.Marshal(&req)
if err != nil {
return SuccessfulResponse{}, 0, err
}
p := url.Values{
"cart": {string(updateJSON)},
}
params, _ := query.Values(filter)
data, status, err := c.PostRequest(fmt.Sprintf("/customer-interaction/%s/cart/clear?%s", site, params.Encode()), p)
if err != nil {
return resp, status, err
}
err = json.Unmarshal(data, &resp)
if err != nil {
return resp, status, err
}
return resp, status, nil
}
// SetCart creates or overwrites shopping cart data
//
// For more information see https://docs.retailcrm.ru/Developers/API/APIVersions/APIv5#post--api-v5-customer-interaction-site-cart-set
//
// Example:
//
// var client = retailcrm.New("https://demo.url", "09jIJ")
//
// data, status, err := client.SetCart("site_id", retailcrm.SiteFilter{SiteBy: "id"},
// retailcrm.SetCartRequest{
// ExternalID: "ext_id",
// DroppedAt: time.Now().String(),
// Link: "link",
// Customer: retailcrm.CartCustomer{
// ID: 1,
// ExternalID: "ext_id",
// Site: "site",
// BrowserID: "browser_id",
// GaClientID: "ga_client_id",
// },
// Items: []retailcrm.SetCartItem{
// {
// Quantity: 1,
// Price: 1.0,
// Offer: retailcrm.SetCartOffer{
// ID: 1,
// ExternalID: "ext_id",
// XMLID: "xml_id",
// },
// },
// },
// },
// )
//
// if err != nil {
// if apiErr, ok := retailcrm.AsAPIError(err); ok {
// log.Fatalf("http status: %d, %s", status, apiErr.String())
// }
//
// log.Fatalf("http status: %d, error: %s", status, err)
// }
func (c *Client) SetCart(site string, filter SiteFilter, req SetCartRequest) (
SuccessfulResponse, int, error,
) {
var resp SuccessfulResponse
updateJSON, err := json.Marshal(&req)
if err != nil {
return SuccessfulResponse{}, 0, err
}
p := url.Values{
"cart": {string(updateJSON)},
}
params, _ := query.Values(filter)
data, status, err := c.PostRequest(fmt.Sprintf("/customer-interaction/%s/cart/set?%s", site, params.Encode()), p)
if err != nil {
return resp, status, err
}
err = json.Unmarshal(data, &resp)
if err != nil {
return resp, status, err
}
return resp, status, nil
}
// GetCart returns the current customer's shopping cart
//
// For more information see https://docs.retailcrm.ru/Developers/API/APIVersions/APIv5#get--api-v5-customer-interaction-site-cart-customerId
//
// Example:
//
// var client = retailcrm.New("https://demo.url", "09jIJ")
//
// data, status, err := client.GetCart("site_id","customer_id",
// retailcrm.GetCartFilter{ SiteBy: "code", By: "externalId"})
//
// if err != nil {
// if apiErr, ok := retailcrm.AsAPIError(err); ok {
// log.Fatalf("http status: %d, %s", status, apiErr.String())
// }
//
// log.Fatalf("http status: %d, error: %s", status, err)
// }
func (c *Client) GetCart(site, customer string, filter GetCartFilter) (CartResponse, int, error) {
var resp CartResponse
params, _ := query.Values(filter)
data, status, err := c.GetRequest(fmt.Sprintf("/customer-interaction/%s/cart/%s?%s", site, customer, params.Encode()))
if err != nil {
return resp, status, err
}
err = json.Unmarshal(data, &resp)
if err != nil {
return resp, status, err
}
return resp, status, nil
}
// DeliveryTracking updates tracking data // DeliveryTracking updates tracking data
// //
// For more information see http://www.simla.com/docs/Developers/API/APIVersions/APIv5#post--api-v5-delivery-generic-subcode-tracking // For more information see http://www.simla.com/docs/Developers/API/APIVersions/APIv5#post--api-v5-delivery-generic-subcode-tracking
@ -5260,7 +4967,7 @@ func (c *Client) StaticticsUpdate() (SuccessfulResponse, int, error) {
// //
// data, status, err := client.Costs(CostsRequest{ // data, status, err := client.Costs(CostsRequest{
// Filter: CostsFilter{ // Filter: CostsFilter{
// IDs: []string{"1","2","3"}, // Ids: []string{"1","2","3"},
// MinSumm: "1000" // MinSumm: "1000"
// }, // },
// }) // })
@ -7055,52 +6762,53 @@ func (c *Client) EditProductsGroup(by, id, site string, group ProductGroup) (Act
// log.Printf("%s", fileData) // log.Printf("%s", fileData)
// } // }
func (c *Client) GetOrderPlate(by, orderID, site string, plateID int) (io.ReadCloser, int, error) { func (c *Client) GetOrderPlate(by, orderID, site string, plateID int) (io.ReadCloser, int, error) {
requestURL := fmt.Sprintf("%s/api/v5/orders/%s/plates/%d/print?%s", c.URL, orderID, plateID, url.Values{ p := url.Values{
"by": {checkBy(by)}, "by": {checkBy(by)},
"site": {site}, "site": {site},
}.Encode()) }
return c.executeWithRetryReadCloser(requestURL, func() (interface{}, int, error) { requestURL := fmt.Sprintf("%s/api/v5/orders/%s/plates/%d/print?%s", c.URL, orderID, plateID, p.Encode())
req, err := http.NewRequest("GET", requestURL, nil) req, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return nil, 0, err
}
req.Header.Set("X-API-KEY", c.Key) if err != nil {
return nil, 0, err
}
if c.Debug { req.Header.Set("X-API-KEY", c.Key)
c.writeLog("API Request: %s %s", requestURL, c.Key)
}
resp, err := c.httpClient.Do(req) if c.Debug {
c.writeLog("API Request: %s %s", requestURL, c.Key)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, 0, err
}
if resp.StatusCode >= http.StatusInternalServerError {
return nil, resp.StatusCode, CreateGenericAPIError(
fmt.Sprintf("HTTP request error. Status code: %d.", resp.StatusCode))
}
if resp.StatusCode >= http.StatusBadRequest && resp.StatusCode < http.StatusInternalServerError {
res, err := buildRawResponse(resp)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
if resp.StatusCode >= http.StatusInternalServerError && resp.StatusCode != http.StatusServiceUnavailable { return nil, resp.StatusCode, CreateAPIError(res)
return nil, resp.StatusCode, CreateGenericAPIError( }
fmt.Sprintf("HTTP request error. Status code: %d.", resp.StatusCode))
}
if resp.StatusCode >= http.StatusBadRequest && reader := resp.Body
resp.StatusCode < http.StatusInternalServerError && err = reader.Close()
resp.StatusCode != http.StatusServiceUnavailable {
res, err := buildRawResponse(resp)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
return nil, resp.StatusCode, CreateAPIError(res) return reader, resp.StatusCode, nil
}
if err != nil {
return nil, 0, err
}
return resp.Body, resp.StatusCode, nil
})
} }
// NotificationsSend send a notification // NotificationsSend send a notification

View file

@ -1,13 +1,8 @@
package retailcrm package retailcrm
import ( import (
"bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"github.com/retailcrm/api-client-go/v2/constant"
"github.com/stretchr/testify/require"
"io"
"io/ioutil" "io/ioutil"
"log" "log"
"math/rand" "math/rand"
@ -86,131 +81,21 @@ func TestBaseURLTrimmed(t *testing.T) {
assert.Equal(t, c1.URL, c3.URL) assert.Equal(t, c1.URL, c3.URL)
} }
func TestGetRequestWithRateLimiter(t *testing.T) { func TestGetRequest(t *testing.T) {
t.Run("Basic 404 response", func(t *testing.T) { c := client()
c := client()
defer gock.Off() defer gock.Off()
gock.New(crmURL). gock.New(crmURL).
Get("/api/v5/fake-method"). Get("/api/v5/fake-method").
Reply(404). Reply(404).
BodyString(`{"success": false, "errorMsg" : "Method not found"}`) BodyString(`{"success": false, "errorMsg" : "Method not found"}`)
_, status, _ := c.GetRequest("/fake-method") _, status, _ := c.GetRequest("/fake-method")
assert.Equal(t, http.StatusNotFound, status) if status != http.StatusNotFound {
}) t.Fail()
}
t.Run("Rate limiter respects configured RPS", func(t *testing.T) {
c := client()
c.EnableRateLimiter(3)
defer gock.Off()
numRequests := 5
for i := 0; i < numRequests; i++ {
gock.New(crmURL).
Get("/api/v5/test-method").
Reply(200).
BodyString(`{"success": true}`)
}
start := time.Now()
for i := 0; i < numRequests; i++ {
_, _, err := c.GetRequest("/test-method")
if err != nil {
t.Fatalf("Request %d failed: %v", i, err)
}
}
elapsed := time.Since(start)
minExpectedTime := time.Duration(numRequests-1) * time.Second / 10
assert.Truef(t, elapsed > minExpectedTime,
"Rate limiter not working correctly. Expected minimum time %v, got %v",
minExpectedTime, elapsed)
})
t.Run("Rate limiter respects telephony endpoint RPS", func(t *testing.T) {
c := client()
c.EnableRateLimiter(3)
defer gock.Off()
numRequests := 5
for i := 0; i < numRequests; i++ {
gock.New(crmURL).
Get("/api/v5/telephony/test-call").
Reply(200).
BodyString(`{"success": true}`)
}
start := time.Now()
for i := 0; i < numRequests; i++ {
_, _, err := c.GetRequest("/telephony/test-call")
if err != nil {
t.Fatalf("Request %d failed: %v", i, err)
}
}
elapsed := time.Since(start)
minExpectedTime := time.Duration(numRequests-1) * time.Second / 40
assert.Truef(t, elapsed > minExpectedTime,
"Rate limiter not working correctly for telephony. Expected minimum time %v, got %v",
minExpectedTime, elapsed)
})
t.Run("Rate limiter retries on 503 responses", func(t *testing.T) {
c := client()
c.EnableRateLimiter(3)
c.Debug = true
defer gock.Off()
gock.New(crmURL).
Get("/api/v5/retry-test").
Reply(503).
BodyString(`{"success": false, "errorMsg": "Rate limit exceeded"}`)
gock.New(crmURL).
Get("/api/v5/retry-test").
Reply(503).
BodyString(`{"success": false, "errorMsg": "Rate limit exceeded"}`)
gock.New(crmURL).
Get("/api/v5/retry-test").
Reply(200).
BodyString(`{"success": true}`)
_, status, err := c.GetRequest("/retry-test")
require.NoErrorf(t, err, "Request failed despite retries: %v", err)
assert.Equal(t, http.StatusOK, status)
assert.True(t, gock.IsDone(), "Not all expected requests were made")
})
t.Run("Rate limiter gives up after max attempts", func(t *testing.T) {
c := client()
c.EnableRateLimiter(2)
defer gock.OffAll()
for i := 0; i < 3; i++ {
gock.New(crmURL).
Get("/api/v5/retry-test").
Reply(503).
BodyString(`{"success": false, "errorMsg": "Rate limit exceeded"}`)
}
_, status, err := c.GetRequest("/retry-test")
assert.Equalf(t, http.StatusServiceUnavailable, status,
"Expected status 503 after max retries, got %d", status)
assert.ErrorIs(t, err, ErrRateLimited, "Expected error after max retries, got nil")
})
} }
func TestPostRequest(t *testing.T) { func TestPostRequest(t *testing.T) {
@ -1881,235 +1766,6 @@ func TestClient_CorporateCustomerEdit(t *testing.T) {
} }
} }
func TestClient_ClearCart(t *testing.T) {
c := client()
tm := "2025-04-14 15:50:00"
clearedAt, err := time.Parse("2006-01-02 15:04:05", tm)
require.NoError(t, err)
site := "site_id"
filter := SiteFilter{SiteBy: "id"}
request := ClearCartRequest{
ClearedAt: clearedAt.UTC().Format(constant.DateTimeWithZoneFormat),
Customer: CartCustomer{
ID: 1,
ExternalID: "ext_id",
Site: "site",
GaClientID: "ga_client_id",
},
Order: ClearCartOrder{
ID: 1,
ExternalID: "ext_id",
Number: "abc123",
},
}
expectedJSON := `{
"clearedAt": "2025-04-14 15:50:00+00:00",
"customer": {
"id": 1,
"externalId": "ext_id",
"site": "site",
"gaClientId": "ga_client_id"
},
"order": {
"id": 1,
"externalId": "ext_id",
"number": "abc123"
}
}`
defer gock.Off()
gock.New(crmURL).
Post(fmt.Sprintf("/customer-interaction/%s/cart/clear", site)).
AddMatcher(func(request *http.Request, _ *gock.Request) (bool, error) {
body, err := io.ReadAll(request.Body)
require.NoError(t, err)
request.Body = io.NopCloser(bytes.NewBuffer(body))
val, err := url.ParseQuery(string(body))
require.NoError(t, err)
val.Get("cart")
if !assert.JSONEq(t, expectedJSON, val.Get("cart")) {
return false, errors.New("unequal values")
}
return true, nil
}).
MatchParam("siteBy", filter.SiteBy).
Reply(200).
BodyString(`{"success":true}`)
data, status, err := c.ClearCart(site, filter, request)
if err != nil {
t.Errorf("%v", err)
}
if status >= http.StatusBadRequest {
t.Errorf("(%d) %v", status, err)
}
if data.Success != true {
t.Errorf("%v", err)
}
}
func TestClient_SetCart(t *testing.T) {
c := client()
site := "site_id"
filter := SiteFilter{SiteBy: "id"}
request := SetCartRequest{
ExternalID: "ext_id",
DroppedAt: time.Now().UTC().Format(constant.DateTimeWithZoneFormat),
Link: "link",
Customer: CartCustomer{
ID: 1,
ExternalID: "ext_id",
Site: "site",
BrowserID: "browser_id",
GaClientID: "ga_client_id",
},
Items: []SetCartItem{
{
Quantity: 1,
Price: 1.0,
Offer: SetCartOffer{
ID: 1,
ExternalID: "ext_id",
XMLID: "xml_id",
},
},
},
}
defer gock.Off()
gock.New(crmURL).
Post(fmt.Sprintf("/customer-interaction/%s/cart/set", site)).
AddMatcher(func(req *http.Request, _ *gock.Request) (bool, error) {
body, err := io.ReadAll(req.Body)
require.NoError(t, err)
req.Body = io.NopCloser(bytes.NewBuffer(body))
val, err := url.ParseQuery(string(body))
require.NoError(t, err)
cartJSON := val.Get("cart")
var cart SetCartRequest
require.NoError(t, json.Unmarshal([]byte(cartJSON), &cart))
equal := assert.Equal(t, "ext_id", cart.ExternalID) &&
assert.NotEmpty(t, cart.DroppedAt) &&
assert.Equal(t, 1, cart.Customer.ID) &&
assert.Equal(t, "ext_id", cart.Customer.ExternalID) &&
assert.Equal(t, "site", cart.Customer.Site) &&
assert.Equal(t, "ga_client_id", cart.Customer.GaClientID) &&
assert.Equal(t, float64(1), cart.Items[0].Quantity) &&
assert.Equal(t, float64(1), cart.Items[0].Price) &&
assert.Equal(t, 1, cart.Items[0].Offer.ID) &&
assert.Equal(t, "ext_id", cart.Items[0].Offer.ExternalID) &&
assert.Equal(t, "xml_id", cart.Items[0].Offer.XMLID)
if !equal {
return false, errors.New("unequal values")
}
return true, nil
}).
MatchParam("siteBy", filter.SiteBy).
Reply(200).
BodyString(`{"success":true}`)
data, status, err := c.SetCart(site, filter, request)
if err != nil {
t.Errorf("%v", err)
}
if status >= http.StatusBadRequest {
t.Errorf("(%d) %v", status, err)
}
if data.Success != true {
t.Errorf("%v", err)
}
}
func TestClient_GetCart(t *testing.T) {
c := client()
site := "site_id"
customer := "customer_id"
filter := GetCartFilter{
SiteBy: "code",
By: "externalId",
}
expCart := Cart{
Currency: "currency",
ExternalID: "ext_id",
DroppedAt: "2025-04-14 14:32:14+03:00",
ClearedAt: "2025-04-14 14:52:14+03:00",
Link: "link",
Items: []CartItem{
{
ID: 1,
Quantity: 2,
Price: 3.0,
Offer: CartOffer{
DisplayName: "name",
ID: 1,
ExternalID: "ext_id",
XMLID: "xml_id",
Name: "name",
Article: "article",
VatRate: "vat_rate",
Properties: StringMap{
"a": "b",
"c": "d",
},
Barcode: "barcode",
},
},
},
}
cartResp := CartResponse{
SuccessfulResponse: SuccessfulResponse{Success: true},
Cart: expCart,
}
defer gock.Off()
gock.New(crmURL).
Get(fmt.Sprintf("/customer-interaction/%s/cart/%s", site, customer)).
MatchParams(map[string]string{
"siteBy": filter.SiteBy,
"by": filter.By,
}).
Reply(200).
JSON(cartResp)
data, status, err := c.GetCart(site, customer, filter)
if err != nil {
t.Errorf("%v", err)
}
if status >= http.StatusBadRequest {
t.Errorf("(%d) %v", status, err)
}
if data.Success != true {
t.Errorf("%v", err)
}
if !reflect.DeepEqual(expCart, data.Cart) {
t.Errorf("%v", err)
}
}
func TestClient_NotesNotes(t *testing.T) { func TestClient_NotesNotes(t *testing.T) {
c := client() c := client()
@ -6558,7 +6214,7 @@ func TestClient_Cost(t *testing.T) {
costs, status, err := c.Costs(CostsRequest{ costs, status, err := c.Costs(CostsRequest{
Filter: CostsFilter{ Filter: CostsFilter{
IDs: []int{id}, Ids: []string{strconv.Itoa(id)},
}, },
Limit: 20, Limit: 20,
Page: 1, Page: 1,

View file

@ -1,3 +0,0 @@
package constant
const DateTimeWithZoneFormat = "2006-01-02 15:04:05-07:00"

View file

@ -9,8 +9,6 @@ import (
var missingParameterMatcher = regexp.MustCompile(`^Parameter \'([\w\]\[\_\-]+)\' is missing$`) var missingParameterMatcher = regexp.MustCompile(`^Parameter \'([\w\]\[\_\-]+)\' is missing$`)
var ( var (
// ErrRateLimited will be returned if request was rate limited.
ErrRateLimited = NewAPIError("rate limit exceeded")
// ErrMissingCredentials will be returned if no API key was provided to the API. // ErrMissingCredentials will be returned if no API key was provided to the API.
ErrMissingCredentials = NewAPIError(`apiKey is missing`) ErrMissingCredentials = NewAPIError(`apiKey is missing`)
// ErrInvalidCredentials will be returned if provided API key is invalid. // ErrInvalidCredentials will be returned if provided API key is invalid.

View file

@ -381,22 +381,22 @@ type ShipmentFilter struct {
// CostsFilter type. // CostsFilter type.
type CostsFilter struct { type CostsFilter struct {
MinSumm int `url:"minSumm,omitempty"` MinSumm string `url:"minSumm,omitempty"`
MaxSumm int `url:"maxSumm,omitempty"` MaxSumm string `url:"maxSumm,omitempty"`
OrderNumber string `url:"orderNumber,omitempty"` OrderNumber string `url:"orderNumber,omitempty"`
Comment string `url:"orderNumber,omitempty"` Comment string `url:"orderNumber,omitempty"`
IDs []int `url:"ids,omitempty,brackets"` Ids []string `url:"ids,omitempty,brackets"`
Sites []string `url:"sites,omitempty,brackets"` Sites []string `url:"sites,omitempty,brackets"`
CreatedBy []int `url:"createdBy,omitempty,brackets"` CreatedBy []string `url:"createdBy,omitempty,brackets"`
CostGroups []string `url:"costGroups,omitempty,brackets"` CostGroups []string `url:"costGroups,omitempty,brackets"`
CostItems []string `url:"costItems,omitempty,brackets"` CostItems []string `url:"costItems,omitempty,brackets"`
Users []int `url:"users,omitempty,brackets"` Users []string `url:"users,omitempty,brackets"`
DateFrom string `url:"dateFrom,omitempty"` DateFrom string `url:"dateFrom,omitempty"`
DateTo string `url:"dateTo,omitempty"` DateTo string `url:"dateTo,omitempty"`
CreatedAtFrom string `url:"createdAtFrom,omitempty"` CreatedAtFrom string `url:"createdAtFrom,omitempty"`
CreatedAtTo string `url:"createdAtTo,omitempty"` CreatedAtTo string `url:"createdAtTo,omitempty"`
OrderIDs []int `url:"orderIds,omitempty,brackets"` OrderIds []string `url:"orderIds,omitempty,brackets"`
OrderExternalIDs []string `url:"orderExternalIds,omitempty,brackets"` OrderExternalIds []string `url:"orderIds,omitempty,brackets"`
} }
// FilesFilter type. // FilesFilter type.
@ -480,18 +480,3 @@ type OffersFilter struct {
Ids []int `url:"ids,omitempty,brackets"` Ids []int `url:"ids,omitempty,brackets"`
Active *int `url:"active,omitempty"` Active *int `url:"active,omitempty"`
} }
type SiteFilter struct {
// SiteBy contains information about what is betrayed site id or site code.
// id|code, default is code.
SiteBy string `url:"siteBy,omitempty"`
}
type GetCartFilter struct {
// SiteBy contains information about what is betrayed site id or site code.
// id|code, default is code.
SiteBy string `url:"siteBy,omitempty"`
// By contains information about what is betrayed: customer id or customer externalId.
// id|externalId, default is externalId.
By string `url:"by,omitempty"`
}

View file

@ -194,22 +194,6 @@ type DeliveryShipmentsRequest struct {
Page int `url:"page,omitempty"` Page int `url:"page,omitempty"`
} }
// ClearCartRequest type.
type ClearCartRequest struct {
ClearedAt string `json:"clearedAt,omitempty"`
Customer CartCustomer `json:"customer,omitempty"`
Order ClearCartOrder `json:"order,omitempty"`
}
// SetCartRequest type.
type SetCartRequest struct {
ExternalID string `json:"externalId,omitempty"`
DroppedAt string `json:"droppedAt,omitempty"`
Link string `json:"link,omitempty"`
Customer CartCustomer `json:"customer,omitempty"`
Items []SetCartItem `json:"items,omitempty"`
}
// CostsRequest type. // CostsRequest type.
type CostsRequest struct { type CostsRequest struct {
Filter CostsFilter `url:"filter,omitempty"` Filter CostsFilter `url:"filter,omitempty"`

View file

@ -407,12 +407,6 @@ type ProductsPropertiesResponse struct {
Properties []Property `json:"properties,omitempty"` Properties []Property `json:"properties,omitempty"`
} }
// CartResponse type.
type CartResponse struct {
SuccessfulResponse
Cart Cart `json:"cart"`
}
// DeliveryShipmentsResponse type. // DeliveryShipmentsResponse type.
type DeliveryShipmentsResponse struct { type DeliveryShipmentsResponse struct {
Success bool `json:"success"` Success bool `json:"success"`

View file

@ -5,28 +5,14 @@ import (
"net/http" "net/http"
"reflect" "reflect"
"strings" "strings"
"sync"
"time"
) )
// ByID is "id" constant to use as `by` property in methods. // ByID is "id" constant to use as `by` property in methods.
const ByID = "id" const ByID = "id"
// ByExternalID is "externalId" constant to use as `by` property in methods. // ByExternalId is "externalId" constant to use as `by` property in methods.
const ByExternalID = "externalId" const ByExternalID = "externalId"
// RateLimiter configuration constants.
const (
regularPathRPS = 10 // API rate limit (requests per second).
telephonyPathRPS = 40 // Telephony API endpoints rate limit (requests per second).
regularDelay = time.Second / regularPathRPS // Delay between regular requests.
telephonyDelay = time.Second / telephonyPathRPS // Delay between telephony requests.
)
// HTTPStatusUnknown can return for the method `/api/v5/customers/upload`, `/api/v5/customers-corporate/upload`,
// `/api/v5/orders/upload`.
const HTTPStatusUnknown = 460
// Client type. // Client type.
type Client struct { type Client struct {
URL string URL string
@ -34,15 +20,6 @@ type Client struct {
Debug bool Debug bool
httpClient *http.Client httpClient *http.Client
logger BasicLogger logger BasicLogger
limiter *RateLimiter
mutex sync.Mutex
}
// RateLimiter manages API request rates to prevent hitting rate limits.
type RateLimiter struct {
maxAttempts uint // Maximum number of retry attempts (0 = infinite).
lastRequest time.Time // Time of the last request.
mutex sync.Mutex
} }
// Pagination type. // Pagination type.
@ -247,15 +224,6 @@ type CorporateCustomerContactCustomer struct {
Site string `json:"site,omitempty"` Site string `json:"site,omitempty"`
} }
// CartCustomer type.
type CartCustomer struct {
ID int `json:"id,omitempty"`
ExternalID string `json:"externalId,omitempty"`
Site string `json:"site,omitempty"`
BrowserID string `json:"browserId,omitempty"`
GaClientID string `json:"gaClientId,omitempty"`
}
type Company struct { type Company struct {
ID int `json:"id,omitempty"` ID int `json:"id,omitempty"`
IsMain bool `json:"isMain,omitempty"` IsMain bool `json:"isMain,omitempty"`
@ -408,13 +376,6 @@ type SerializedOrderLink struct {
Orders []LinkedOrder `json:"orders,omitempty"` Orders []LinkedOrder `json:"orders,omitempty"`
} }
// ClearCartOrder type.
type ClearCartOrder struct {
ID int `json:"id,omitempty"`
ExternalID string `json:"externalId,omitempty"`
Number string `json:"number,omitempty"`
}
// ClientID type. // ClientID type.
type ClientID struct { type ClientID struct {
Value string `json:"value"` Value string `json:"value"`
@ -485,59 +446,6 @@ type OrderDeliveryData struct {
AdditionalFields map[string]interface{} AdditionalFields map[string]interface{}
} }
// SetCartItem type.
type SetCartItem struct {
Quantity float64 `json:"quantity,omitempty"`
Price float64 `json:"price,omitempty"`
Offer SetCartOffer `json:"offer,omitempty"`
}
// SetCartOffer type.
type SetCartOffer struct {
ID int `json:"id,omitempty"`
ExternalID string `json:"externalId,omitempty"`
XMLID string `json:"xmlId,omitempty"`
}
// Cart type.
type Cart struct {
Currency string `json:"currency,omitempty"`
ExternalID string `json:"externalId,omitempty"`
DroppedAt string `json:"droppedAt,omitempty"`
ClearedAt string `json:"clearedAt,omitempty"`
Link string `json:"link,omitempty"`
Items []CartItem `json:"items,omitempty"`
}
// CartItem type.
type CartItem struct {
ID int `json:"id,omitempty"`
Quantity float64 `json:"quantity,omitempty"`
Price float64 `json:"price,omitempty"`
Offer CartOffer `json:"offer,omitempty"`
}
// CartOffer type.
type CartOffer struct {
DisplayName string `json:"displayName,omitempty"`
ID int `json:"id,omitempty"`
ExternalID string `json:"externalId,omitempty"`
XMLID string `json:"xmlId,omitempty"`
Name string `json:"name,omitempty"`
Article string `json:"article,omitempty"`
VatRate string `json:"vatRate,omitempty"`
Properties StringMap `json:"properties,omitempty"`
Unit CartUnit `json:"unit,omitempty"`
Barcode string `json:"barcode,omitempty"`
}
// CartUnit type.
type CartUnit struct {
Code string `json:"code"`
Name string `json:"name"`
Sym string `json:"sym"`
}
// UnmarshalJSON method. // UnmarshalJSON method.
func (v *OrderDeliveryData) UnmarshalJSON(b []byte) error { func (v *OrderDeliveryData) UnmarshalJSON(b []byte) error {
var additionalData map[string]interface{} var additionalData map[string]interface{}