mirror of
https://github.com/retailcrm/api-client-go.git
synced 2025-04-06 22:53:30 +03:00
Compare commits
No commits in common. "master" and "v2.1.16" have entirely different histories.
10 changed files with 168 additions and 965 deletions
24
.github/workflows/ci.yml
vendored
24
.github/workflows/ci.yml
vendored
|
@ -20,10 +20,10 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code into the Go module directory
|
- name: Check out code into the Go module directory
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
- name: Set up Go 1.23
|
- name: Set up Go 1.17
|
||||||
uses: actions/setup-go@v3
|
uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: '1.23'
|
go-version: '1.17'
|
||||||
- name: Get dependencies
|
- name: Get dependencies
|
||||||
run: |
|
run: |
|
||||||
go mod tidy
|
go mod tidy
|
||||||
|
@ -31,7 +31,7 @@ jobs:
|
||||||
- name: Lint code with golangci-lint
|
- name: Lint code with golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v3
|
uses: golangci/golangci-lint-action@v3
|
||||||
with:
|
with:
|
||||||
version: v1.62.2
|
version: v1.50.1
|
||||||
only-new-issues: true
|
only-new-issues: true
|
||||||
skip-pkg-cache: true
|
skip-pkg-cache: true
|
||||||
args: --build-tags=testutils
|
args: --build-tags=testutils
|
||||||
|
@ -40,13 +40,13 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
go-version: ['1.19', '1.20', '1.21', '1.22', '1.23', 'stable']
|
go-version: ['1.13', '1.14', '1.15', '1.16', '1.17']
|
||||||
include:
|
include:
|
||||||
- go-version: '1.23'
|
- go-version: '1.17'
|
||||||
coverage: 1
|
coverage: 1
|
||||||
steps:
|
steps:
|
||||||
- name: Set up Go ${{ matrix.go-version }}
|
- name: Set up Go ${{ matrix.go-version }}
|
||||||
uses: actions/setup-go@v3
|
uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: ${{ matrix.go-version }}
|
go-version: ${{ matrix.go-version }}
|
||||||
- name: Check out code into the Go module directory
|
- name: Check out code into the Go module directory
|
||||||
|
@ -59,21 +59,17 @@ jobs:
|
||||||
env:
|
env:
|
||||||
COVERAGE: ${{ matrix.coverage }}
|
COVERAGE: ${{ matrix.coverage }}
|
||||||
if: env.COVERAGE != 1
|
if: env.COVERAGE != 1
|
||||||
run: |
|
run: go test -tags=testutils ./...
|
||||||
go install gotest.tools/gotestsum@latest
|
|
||||||
gotestsum --format testdox ./... -tags=testutils -v -cpu 2 -timeout 60s -race
|
|
||||||
- name: Tests with coverage
|
- name: Tests with coverage
|
||||||
env:
|
env:
|
||||||
COVERAGE: ${{ matrix.coverage }}
|
COVERAGE: ${{ matrix.coverage }}
|
||||||
if: env.COVERAGE == 1
|
if: env.COVERAGE == 1
|
||||||
run: |
|
run: |
|
||||||
go install gotest.tools/gotestsum@latest
|
go test -tags=testutils ./... -race -coverprofile=coverage.txt -covermode=atomic "$d"
|
||||||
gotestsum --format testdox ./... -tags=testutils -v -cpu 2 -timeout 60s -race -cover -coverprofile=coverage.txt -covermode=atomic "$d"
|
|
||||||
- name: Coverage
|
- name: Coverage
|
||||||
env:
|
env:
|
||||||
COVERAGE: ${{ matrix.coverage }}
|
COVERAGE: ${{ matrix.coverage }}
|
||||||
if: env.COVERAGE == 1
|
if: env.COVERAGE == 1
|
||||||
run: |
|
run: |
|
||||||
go install github.com/axw/gocov/gocov@latest
|
|
||||||
gocov convert ./coverage.txt | gocov report
|
|
||||||
bash <(curl -s https://codecov.io/bash)
|
bash <(curl -s https://codecov.io/bash)
|
||||||
|
rm coverage.txt
|
||||||
|
|
|
@ -1,37 +1,11 @@
|
||||||
run:
|
run:
|
||||||
skip-dirs-use-default: true
|
skip-dirs-use-default: true
|
||||||
allow-parallel-runners: true
|
allow-parallel-runners: true
|
||||||
|
skip-files:
|
||||||
issues:
|
|
||||||
exclude-files:
|
|
||||||
- testutils.go
|
- testutils.go
|
||||||
exclude-rules:
|
|
||||||
- path: _test\.go
|
|
||||||
linters:
|
|
||||||
- lll
|
|
||||||
- errcheck
|
|
||||||
- misspell
|
|
||||||
- ineffassign
|
|
||||||
- whitespace
|
|
||||||
- makezero
|
|
||||||
- errcheck
|
|
||||||
- funlen
|
|
||||||
- goconst
|
|
||||||
- gocognit
|
|
||||||
- gocyclo
|
|
||||||
- godot
|
|
||||||
- unused
|
|
||||||
- errchkjson
|
|
||||||
- varnamelen
|
|
||||||
exclude-use-default: true
|
|
||||||
exclude-case-sensitive: false
|
|
||||||
max-issues-per-linter: 0
|
|
||||||
max-same-issues: 0
|
|
||||||
fix: true
|
|
||||||
|
|
||||||
output:
|
output:
|
||||||
formats:
|
format: colored-line-number
|
||||||
- format: colored-line-number
|
|
||||||
sort-results: true
|
sort-results: true
|
||||||
|
|
||||||
# Linters below do not support go1.18 yet because of generics.
|
# Linters below do not support go1.18 yet because of generics.
|
||||||
|
@ -64,7 +38,6 @@ linters:
|
||||||
- errorlint
|
- errorlint
|
||||||
- exhaustive
|
- exhaustive
|
||||||
- exportloopref
|
- exportloopref
|
||||||
- copyloopvar
|
|
||||||
- funlen
|
- funlen
|
||||||
- gocognit
|
- gocognit
|
||||||
- goconst
|
- goconst
|
||||||
|
@ -80,6 +53,7 @@ linters:
|
||||||
- nestif
|
- nestif
|
||||||
- prealloc
|
- prealloc
|
||||||
- predeclared
|
- predeclared
|
||||||
|
- exportloopref
|
||||||
- unconvert
|
- unconvert
|
||||||
- whitespace
|
- whitespace
|
||||||
|
|
||||||
|
@ -204,10 +178,34 @@ linters-settings:
|
||||||
- t *testing.T
|
- t *testing.T
|
||||||
- e error
|
- e error
|
||||||
- i int
|
- i int
|
||||||
|
issues:
|
||||||
|
exclude-rules:
|
||||||
|
- path: _test\.go
|
||||||
|
linters:
|
||||||
|
- lll
|
||||||
|
- errcheck
|
||||||
|
- misspell
|
||||||
|
- ineffassign
|
||||||
|
- whitespace
|
||||||
|
- makezero
|
||||||
|
- errcheck
|
||||||
|
- funlen
|
||||||
|
- goconst
|
||||||
|
- gocognit
|
||||||
|
- gocyclo
|
||||||
|
- godot
|
||||||
|
- unused
|
||||||
|
- errchkjson
|
||||||
|
- varnamelen
|
||||||
|
exclude-use-default: true
|
||||||
|
exclude-case-sensitive: false
|
||||||
|
max-issues-per-linter: 0
|
||||||
|
max-same-issues: 0
|
||||||
|
fix: true
|
||||||
|
|
||||||
severity:
|
severity:
|
||||||
default-severity: error
|
default-severity: error
|
||||||
case-sensitive: false
|
case-sensitive: false
|
||||||
|
|
||||||
service:
|
service:
|
||||||
golangci-lint-version: 1.62.x
|
golangci-lint-version: 1.50.x
|
||||||
|
|
500
client.go
500
client.go
|
@ -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
|
||||||
|
|
390
client_test.go
390
client_test.go
|
@ -3,7 +3,6 @@ package retailcrm
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"log"
|
"log"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
@ -82,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) {
|
||||||
|
@ -1877,171 +1766,6 @@ func TestClient_CorporateCustomerEdit(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClient_ClearCart(t *testing.T) {
|
|
||||||
c := client()
|
|
||||||
|
|
||||||
site := "site_id"
|
|
||||||
filter := SiteFilter{SiteBy: "id"}
|
|
||||||
request := ClearCartRequest{
|
|
||||||
ClearedAt: time.Now().String(),
|
|
||||||
Customer: CartCustomer{
|
|
||||||
ID: 1,
|
|
||||||
ExternalID: "ext_id",
|
|
||||||
Site: "site",
|
|
||||||
BrowserID: "browser_id",
|
|
||||||
GaClientID: "ga_client_id",
|
|
||||||
},
|
|
||||||
Order: ClearCartOrder{
|
|
||||||
ID: 1,
|
|
||||||
ExternalID: "ext_id",
|
|
||||||
Number: "abc123",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
defer gock.Off()
|
|
||||||
gock.New(crmURL).
|
|
||||||
Post(fmt.Sprintf("/customer-interaction/%s/cart/clear", site)).
|
|
||||||
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().String(),
|
|
||||||
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)).
|
|
||||||
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: time.Now().String(),
|
|
||||||
ClearedAt: time.Now().String(),
|
|
||||||
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()
|
||||||
|
|
||||||
|
@ -4098,95 +3822,7 @@ func TestClient_PriceTypes(t *testing.T) {
|
||||||
gock.New(crmURL).
|
gock.New(crmURL).
|
||||||
Get("/reference/price-types").
|
Get("/reference/price-types").
|
||||||
Reply(200).
|
Reply(200).
|
||||||
BodyString(`
|
BodyString(`{"success": true}`)
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"priceTypes": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"code": "base",
|
|
||||||
"name": "Base",
|
|
||||||
"active": true,
|
|
||||||
"promo": false,
|
|
||||||
"default": true,
|
|
||||||
"geo": [],
|
|
||||||
"groups": [],
|
|
||||||
"ordering": 1,
|
|
||||||
"currency": "MXN"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 4,
|
|
||||||
"code": "cop",
|
|
||||||
"name": "COP",
|
|
||||||
"active": false,
|
|
||||||
"promo": false,
|
|
||||||
"default": false,
|
|
||||||
"geo": [],
|
|
||||||
"groups": [
|
|
||||||
"manager_1"
|
|
||||||
],
|
|
||||||
"ordering": 990,
|
|
||||||
"currency": "MXN"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"code": "promo_test",
|
|
||||||
"name": "Promotional",
|
|
||||||
"active": false,
|
|
||||||
"promo": true,
|
|
||||||
"default": false,
|
|
||||||
"geo": [
|
|
||||||
{
|
|
||||||
"country": "CO",
|
|
||||||
"regionId": "",
|
|
||||||
"region": "Todas las regiones",
|
|
||||||
"cityId": "",
|
|
||||||
"city": "All"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"country": "ES",
|
|
||||||
"regionId": "",
|
|
||||||
"region": "Todas las regiones",
|
|
||||||
"cityId": "",
|
|
||||||
"city": "All"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"country": "MX",
|
|
||||||
"regionId": "",
|
|
||||||
"region": "Todas las regiones",
|
|
||||||
"cityId": "",
|
|
||||||
"city": "All"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"groups": [],
|
|
||||||
"ordering": 990,
|
|
||||||
"currency": "MXN"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"code": "wholesale",
|
|
||||||
"name": "Wholesale",
|
|
||||||
"active": false,
|
|
||||||
"promo": true,
|
|
||||||
"default": false,
|
|
||||||
"description": "Solo para ventas al mayoreo con mas de 10 articulos.",
|
|
||||||
"filterExpression": "order.quantity >= 1",
|
|
||||||
"geo": [
|
|
||||||
{
|
|
||||||
"country": "MX",
|
|
||||||
"regionId": "",
|
|
||||||
"region": "Todas las regiones",
|
|
||||||
"cityId": "",
|
|
||||||
"city": "All"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"groups": [],
|
|
||||||
"ordering": 990,
|
|
||||||
"currency": "MXN"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
|
|
||||||
data, st, err := c.PriceTypes()
|
data, st, err := c.PriceTypes()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -6490,7 +6126,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,
|
||||||
|
|
2
error.go
2
error.go
|
@ -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.
|
||||||
|
|
29
filters.go
29
filters.go
|
@ -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"`
|
|
||||||
}
|
|
||||||
|
|
16
request.go
16
request.go
|
@ -194,22 +194,6 @@ type DeliveryShipmentsRequest struct {
|
||||||
Page int `url:"page,omitempty"`
|
Page int `url:"page,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearCartRequest type.
|
|
||||||
type ClearCartRequest struct {
|
|
||||||
ClearedAt string `url:"clearedAt,omitempty"`
|
|
||||||
Customer CartCustomer `url:"customer,omitempty"`
|
|
||||||
Order ClearCartOrder `url:"order,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetCartRequest type.
|
|
||||||
type SetCartRequest struct {
|
|
||||||
ExternalID string `url:"externalId,omitempty"`
|
|
||||||
DroppedAt string `url:"droppedAt,omitempty"`
|
|
||||||
Link string `url:"link,omitempty"`
|
|
||||||
Customer CartCustomer `url:"customer,omitempty"`
|
|
||||||
Items []SetCartItem `url:"items,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CostsRequest type.
|
// CostsRequest type.
|
||||||
type CostsRequest struct {
|
type CostsRequest struct {
|
||||||
Filter CostsFilter `url:"filter,omitempty"`
|
Filter CostsFilter `url:"filter,omitempty"`
|
||||||
|
|
|
@ -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"`
|
||||||
|
|
|
@ -23,7 +23,7 @@ func (st SystemTime) MarshalJSON() ([]byte, error) {
|
||||||
return []byte(st.String()), nil
|
return []byte(st.String()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// String returns the time in the custom format.
|
// String returns the time in the custom format
|
||||||
func (st *SystemTime) String() string {
|
func (st *SystemTime) String() string {
|
||||||
t := time.Time(*st)
|
t := time.Time(*st)
|
||||||
return fmt.Sprintf("%q", t.Format(systemTimeLayout))
|
return fmt.Sprintf("%q", t.Format(systemTimeLayout))
|
||||||
|
|
106
types.go
106
types.go
|
@ -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.
|
||||||
|
@ -76,16 +53,13 @@ type Address struct {
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GeoID type. Can be empty string.
|
|
||||||
type GeoID json.Number
|
|
||||||
|
|
||||||
// GeoHierarchyRow type.
|
// GeoHierarchyRow type.
|
||||||
type GeoHierarchyRow struct {
|
type GeoHierarchyRow struct {
|
||||||
Country string `json:"country,omitempty"`
|
Country string `json:"country,omitempty"`
|
||||||
Region string `json:"region,omitempty"`
|
Region string `json:"region,omitempty"`
|
||||||
RegionID GeoID `json:"regionId,omitempty"`
|
RegionID int `json:"regionId,omitempty"`
|
||||||
City string `json:"city,omitempty"`
|
City string `json:"city,omitempty"`
|
||||||
CityID GeoID `json:"cityId,omitempty"`
|
CityID int `json:"cityId,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source type.
|
// Source type.
|
||||||
|
@ -247,15 +221,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 +373,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 +443,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{}
|
||||||
|
@ -1356,9 +1261,8 @@ type MgTransport struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type MgTransportActions struct {
|
type MgTransportActions struct {
|
||||||
Visits string `json:"visits,omitempty"`
|
Visits string `json:"visits,omitempty"`
|
||||||
Online string `json:"online,omitempty"`
|
Online string `json:"online,omitempty"`
|
||||||
ManualTemplatesSync string `json:"manualTemplatesSync,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MgBot type.
|
// MgBot type.
|
||||||
|
|
Loading…
Add table
Reference in a new issue