Correct v2 (#55)

* refactor library and upgrade version
* remove useless environment variable
* remove unsupported versions from go.mod
* fixes for error handling & data types
* different improvements for errors
* fixes for types and tests
* better coverage, fix error with the unmarshalers
This commit is contained in:
Pavel 2021-10-27 15:49:06 +03:00 committed by GitHub
parent 269764175e
commit 97b1abf470
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 2173 additions and 1193 deletions

View File

@ -9,7 +9,6 @@ on:
pull_request:
env:
DEVELOPER_NODE: 1
RETAILCRM_URL: https://test.retailcrm.pro
RETAILCRM_KEY: key
@ -41,7 +40,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
go-version: ['1.11', '1.12', '1.13', '1.14', '1.15', '1.16', '1.17']
go-version: ['1.13', '1.14', '1.15', '1.16', '1.17']
include:
- go-version: '1.17'
coverage: 1

1
.gitignore vendored
View File

@ -5,6 +5,7 @@
# Folders
_obj
vendor
# Architecture specific extensions/prefixes
*.[568vq]

View File

@ -146,9 +146,9 @@ linters-settings:
gocyclo:
min-complexity: 25
goimports:
local-prefixes: github.com/retailcrm/api-client-go
local-prefixes: github.com/retailcrm/api-client-go/v2
lll:
line-length: 120
line-length: 160
maligned:
suggest-new: true
misspell:
@ -180,6 +180,9 @@ issues:
- gocognit
- gocyclo
- godot
- gocritic
- gosec
- staticcheck
exclude-use-default: true
exclude-case-sensitive: false
max-issues-per-linter: 0

103
README.md
View File

@ -10,51 +10,51 @@
This is golang RetailCRM API client.
## Install
## Installation
```bash
go get -x github.com/retailcrm/api-client-go
go get -u github.com/retailcrm/api-client-go/v2
```
## Usage
```golang
Example:
```go
package main
import (
"fmt"
"net/http"
"log"
"github.com/retailcrm/api-client-go/v5"
"github.com/retailcrm/api-client-go/v2"
)
func main() {
var client = v5.New("https://demo.retailcrm.pro", "09jIJ09j0JKhgyfvyuUIKhiugF")
var client = retailcrm.New("https://demo.retailcrm.pro", "09jIJ09j0JKhgyfvyuUIKhiugF")
data, status, err := client.Orders(v5.OrdersRequest{
Filter: v5.OrdersFilter{},
data, status, err := client.Orders(retailcrm.OrdersRequest{
Filter: retailcrm.OrdersFilter{},
Limit: 20,
Page: 1,
},)
})
if err != nil {
fmt.Printf("%v", err.Error())
if apiErr, ok := retailcrm.AsAPIError(err); ok {
log.Fatalf("http status: %d, %s", status, apiErr.String())
}
if status >= http.StatusBadRequest {
fmt.Printf("%v", err.ApiError())
log.Fatalf("http status: %d, error: %s", status, err)
}
for _, value := range data.Orders {
fmt.Printf("%v\n", value.Email)
log.Printf("%v\n", value.Email)
}
fmt.Println(data.Orders[1].FirstName)
log.Println(data.Orders[1].FirstName)
idata, status, err := c.InventoriesUpload(
[]InventoryUpload{
inventories, status, err := client.InventoriesUpload([]retailcrm.InventoryUpload{
{
XMLID: "pTKIKAeghYzX21HTdzFCe1",
Stores: []InventoryUploadStore{
Stores: []retailcrm.InventoryUploadStore{
{
Code: "test-store-v5",
Available: 10,
@ -74,7 +74,7 @@ func main() {
},
{
XMLID: "JQIvcrCtiSpOV3AAfMiQB3",
Stores: []InventoryUploadStore{
Stores: []retailcrm.InventoryUploadStore{
{
Code: "test-store-v5",
Available: 45,
@ -95,13 +95,70 @@ func main() {
},
)
if err != nil {
fmt.Printf("%v", err.Error())
if apiErr, ok := retailcrm.AsAPIError(err); ok {
log.Fatalf("http status: %d, %s", status, apiErr.String())
}
if status >= http.StatusBadRequest {
fmt.Printf("%v", err.ApiError())
log.Fatalf("http status: %d, error: %s", status, err)
}
fmt.Println(idata.processedOffersCount)
log.Println(inventories.ProcessedOffersCount)
}
```
You can use different error types and `retailcrm.AsAPIError` to process client errors. Example:
```go
package retailcrm
import (
"errors"
"log"
"os"
"strings"
"github.com/retailcrm/api-client-go/v2"
)
func main() {
var client = retailcrm.New("https://demo.retailcrm.pro", "09jIJ09j0JKhgyfvyuUIKhiugF")
resp, status, err := client.APICredentials()
if err != nil {
apiErr, ok := retailcrm.AsAPIError(err)
if !ok {
log.Fatalf("http status: %d, error: %s", status, err)
}
switch {
case errors.Is(apiErr, retailcrm.ErrMissingCredentials):
log.Fatalln("No API key provided.")
case errors.Is(apiErr, retailcrm.ErrInvalidCredentials):
log.Fatalln("Invalid API key.")
case errors.Is(apiErr, retailcrm.ErrAccessDenied):
log.Fatalln("Access denied. Please check that the provided key has access to the credentials info.")
case errors.Is(apiErr, retailcrm.ErrAccountDoesNotExist):
log.Fatalln("There is no RetailCRM at the provided URL.")
case errors.Is(apiErr, retailcrm.ErrMissingParameter):
// retailcrm.APIError in this case will always contain "Name" key in the errors list with the parameter name.
log.Fatalln("This parameter should be present:", apiErr.Errors()["Name"])
case errors.Is(apiErr, retailcrm.ErrValidation):
log.Println("Validation errors from the API:")
for name, value := range apiErr.Errors() {
log.Printf(" - %s: %s\n", name, value)
}
os.Exit(1)
case errors.Is(apiErr, retailcrm.ErrGeneric):
log.Fatalf("failure from the API. %s", apiErr.String())
}
}
log.Println("Available scopes:", strings.Join(resp.Scopes, ", "))
}
```
## Upgrading
Please check the [UPGRADING.md](UPGRADING.md) to learn how to upgrade to the new version.

70
UPGRADING.md Normal file
View File

@ -0,0 +1,70 @@
# Upgrading to the v2
### Install the new version
```bash
go get -u github.com/retailcrm/api-client-go/v2
```
### Update all imports
Before:
```go
package main
import v5 "github.com/retailcrm/api-client-go/v5"
```
After:
```go
package main
import "github.com/retailcrm/api-client-go/v2"
```
You can use package alias `v5` to skip the second step.
### Replace package name for all imported symbols
Before:
```go
package main
import v5 "github.com/retailcrm/api-client-go/v5"
func main() {
client := v5.New("https://test.retailcrm.pro", "key")
data, status, err := client.Orders(v5.OrdersRequest{
Filter: v5.OrdersFilter{
City: "Moscow",
},
Page: 1,
})
...
}
```
After:
```go
package main
import "github.com/retailcrm/api-client-go/v2"
func main() {
client := retailcrm.New("https://test.retailcrm.pro", "key")
data, status, err := client.Orders(retailcrm.OrdersRequest{
Filter: retailcrm.OrdersFilter{
City: "Moscow",
},
Page: 1,
})
...
}
```
### Upgrade client usages
This major release contains some breaking changes regarding field names and fully redesigned error handling. Use the second example from
the readme to learn how to process errors correctly.

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
package v5
package retailcrm
import (
"encoding/json"
@ -20,14 +20,12 @@ import (
)
func TestMain(m *testing.M) {
if os.Getenv("DEVELOPER_NODE") == "1" {
err := godotenv.Load("../.env")
err := godotenv.Load(".env")
if err != nil {
log.Fatal("Error loading .env file")
}
os.Exit(m.Run())
}
}
func init() {
@ -494,7 +492,7 @@ func TestClient_CustomersUpload_Fail(t *testing.T) {
MatchType("url").
BodyString(p.Encode()).
Reply(460).
BodyString(`{"success": false, "errorMsg": "Customers are loaded with errors"}`)
BodyString(`{"success": false, "errorMsg": "Customers are loaded with ErrorsList"}`)
data, _, err := c.CustomersUpload(customers)
if err == nil {
@ -631,7 +629,7 @@ func TestClient_CustomersFixExternalIds_Fail(t *testing.T) {
MatchType("url").
BodyString(p.Encode()).
Reply(400).
BodyString(`{"success": false, "errorMsg": "Errors in the input parameters", "errors": {"id": "ID must be an integer"}}`)
BodyString(`{"success": false, "errorMsg": "Errors in the input parameters", "ErrorsList": {"id": "ID must be an integer"}}`)
data, _, err := c.CustomersFixExternalIds(customers)
if err == nil {
@ -690,7 +688,7 @@ func TestClient_CustomersHistory_Fail(t *testing.T) {
Get("/customers/history").
MatchParam("filter[startDate]", "2020-13-12").
Reply(400).
BodyString(`{"success": false, "errorMsg": "Errors in the input parameters", "errors": {"children[startDate]": "Значение недопустимо."}}`)
BodyString(`{"success": false, "errorMsg": "Errors in the input parameters", "ErrorsList": {"children[startDate]": "Значение недопустимо."}}`)
data, _, err := c.CustomersHistory(f)
if err == nil {
@ -885,7 +883,7 @@ func TestClient_CorporateCustomersFixExternalIds_Fail(t *testing.T) {
MatchType("url").
BodyString(p.Encode()).
Reply(400).
BodyString(`{"success": false, "errorMsg": "Errors in the input parameters", "errors": {"id": "ID must be an integer"}}`)
BodyString(`{"success": false, "errorMsg": "Errors in the input parameters", "ErrorsList": {"id": "ID must be an integer"}}`)
data, _, err := c.CorporateCustomersFixExternalIds(customers)
if err == nil {
@ -944,7 +942,7 @@ func TestClient_CorporateCustomersHistory_Fail(t *testing.T) {
Get("/customers-corporate/history").
MatchParam("filter[startDate]", "2020-13-12").
Reply(400).
BodyString(`{"success": false, "errorMsg": "Errors in the input parameters", "errors": {"children[startDate]": "Значение недопустимо."}}`)
BodyString(`{"success": false, "errorMsg": "Errors in the input parameters", "ErrorsList": {"children[startDate]": "Значение недопустимо."}}`)
data, _, err := c.CorporateCustomersHistory(f)
if err == nil {
@ -1130,7 +1128,7 @@ func TestClient_CorporateCustomersUpload_Fail(t *testing.T) {
MatchType("url").
BodyString(p.Encode()).
Reply(460).
BodyString(`{"success": false, "errorMsg": "Customers are loaded with errors"}`)
BodyString(`{"success": false, "errorMsg": "Customers are loaded with ErrorsList"}`)
data, _, err := c.CorporateCustomersUpload(customers)
if err == nil {
@ -1703,7 +1701,7 @@ func TestClient_NotesNotes_Fail(t *testing.T) {
Get("/customers/notes").
MatchParam("filter[createdAtFrom]", "2020-13-12").
Reply(400).
BodyString(`{"success": false, "errorMsg": "Errors in the input parameters", "errors": {"children[createdAtFrom]": "This value is not valid."}}`)
BodyString(`{"success": false, "errorMsg": "Errors in the input parameters", "ErrorsList": {"children[createdAtFrom]": "This value is not valid."}}`)
data, _, err := c.CustomerNotes(NotesRequest{
Filter: NotesFilter{CreatedAtFrom: "2020-13-12"},
@ -1803,7 +1801,7 @@ func TestClient_NotesCreateDelete_Fail(t *testing.T) {
MatchType("url").
BodyString(p.Encode()).
Reply(400).
BodyString(`{"success": false, "errorMsg": "Errors in the entity format", "errors": {"customer": "Set one of the following fields: id, externalId"}}`)
BodyString(`{"success": false, "errorMsg": "Errors in the entity format", "ErrorsList": {"customer": "Set one of the following fields: id, externalId"}}`)
data, _, err := c.CustomerNoteCreate(note)
if err == nil {
@ -1875,7 +1873,7 @@ func TestClient_OrdersOrders_Fail(t *testing.T) {
Get("/orders").
MatchParam("filter[attachments]", "7").
Reply(400).
BodyString(`{"success": false, "errorMsg": "Errors in the input parameters", "errors": {"children[attachments]": "SThis value is not valid."}}`)
BodyString(`{"success": false, "errorMsg": "Errors in the input parameters", "ErrorsList": {"children[attachments]": "SThis value is not valid."}}`)
data, _, err := c.Orders(OrdersRequest{Filter: OrdersFilter{Attachments: 7}})
if err == nil {
@ -2166,7 +2164,7 @@ func TestClient_OrdersUpload_Fail(t *testing.T) {
MatchType("url").
BodyString(p.Encode()).
Reply(460).
BodyString(`{"success": false, "errorMsg": "Orders are loaded with errors"}`)
BodyString(`{"success": false, "errorMsg": "Orders are loaded with ErrorsList"}`)
data, _, err := c.OrdersUpload(orders)
if err == nil {
@ -2395,7 +2393,7 @@ func TestClient_OrdersHistory_Fail(t *testing.T) {
Get("/orders/history").
MatchParam("filter[startDate]", "2020-13-12").
Reply(400).
BodyString(`{"success": false, "errorMsg": "Errors in the input parameters", "errors": {"children[startDate]": "Значение недопустимо."}}`)
BodyString(`{"success": false, "errorMsg": "Errors in the input parameters", "ErrorsList": {"children[startDate]": "Значение недопустимо."}}`)
data, _, err := c.OrdersHistory(OrdersHistoryRequest{Filter: OrdersHistoryFilter{StartDate: "2020-13-12"}})
if err == nil {
@ -2521,7 +2519,7 @@ func TestClient_PaymentCreateEditDelete_Fail(t *testing.T) {
MatchType("url").
BodyString(p.Encode()).
Reply(400).
BodyString(`{"success": false, "errorMsg": "Errors in the entity format", "errors": {"order": "Set one of the following fields: id, externalId, number"}}`)
BodyString(`{"success": false, "errorMsg": "Errors in the entity format", "ErrorsList": {"order": "Set one of the following fields: id, externalId, number"}}`)
data, _, err := c.OrderPaymentCreate(f)
if err == nil {
@ -2743,7 +2741,7 @@ func TestClient_TaskChange_Fail(t *testing.T) {
MatchType("url").
BodyString(p.Encode()).
Reply(400).
BodyString(`{"success": false, "errorMsg": "Task is not loaded", "errors": {"performerId": "This value should not be blank."}}`)
BodyString(`{"success": false, "errorMsg": "Task is not loaded", "ErrorsList": {"performerId": "This value should not be blank."}}`)
data, _, err := c.TaskEdit(f)
if err == nil {
@ -2792,7 +2790,7 @@ func TestClient_UsersUsers_Fail(t *testing.T) {
MatchParam("filter[active]", "3").
MatchParam("page", "1").
Reply(400).
BodyString(`{"success": false, "errorMsg": "Errors in the input parameters", "errors": {"active": "he value you selected is not a valid choice."}}`)
BodyString(`{"success": false, "errorMsg": "Errors in the input parameters", "ErrorsList": {"active": "he value you selected is not a valid choice."}}`)
data, _, err := c.Users(UsersRequest{Filter: UsersFilter{Active: 3}, Page: 1})
if err == nil {
@ -3550,8 +3548,8 @@ func TestClient_Courier(t *testing.T) {
cur := Courier{
Active: true,
Email: fmt.Sprintf("%s@example.com", RandomString(5)),
FirstName: fmt.Sprintf("%s", RandomString(5)),
LastName: fmt.Sprintf("%s", RandomString(5)),
FirstName: RandomString(5),
LastName: RandomString(5),
}
defer gock.Off()
@ -3583,7 +3581,7 @@ func TestClient_Courier(t *testing.T) {
}
cur.ID = data.ID
cur.Patronymic = fmt.Sprintf("%s", RandomString(5))
cur.Patronymic = RandomString(5)
jr, _ = json.Marshal(&cur)
@ -3629,7 +3627,7 @@ func TestClient_Courier_Fail(t *testing.T) {
MatchType("url").
BodyString(p.Encode()).
Reply(400).
BodyString(`{"success": false, "errorMsg": "Errors in the entity format", "errors": {"firstName": "Specify the first name"}}`)
BodyString(`{"success": false, "errorMsg": "Errors in the entity format", "ErrorsList": {"firstName": "Specify the first name"}}`)
data, st, err := c.CourierCreate(Courier{})
if err == nil {
@ -3644,7 +3642,7 @@ func TestClient_Courier_Fail(t *testing.T) {
t.Error(successFail)
}
cur := Courier{Patronymic: fmt.Sprintf("%s", RandomString(5))}
cur := Courier{Patronymic: RandomString(5)}
jr, _ = json.Marshal(&cur)
p = url.Values{
@ -5793,7 +5791,7 @@ func TestClient_CostsUpload_Fail(t *testing.T) {
MatchType("url").
BodyString(p.Encode()).
Reply(460).
BodyString(`{"success": false, "errorMsg": "Costs are loaded with errors"}`)
BodyString(`{"success": false, "errorMsg": "Costs are loaded with ErrorsList"}`)
data, _, err := c.CostsUpload(costsUpload)
if err == nil {

192
error.go Normal file
View File

@ -0,0 +1,192 @@
package retailcrm
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
var missingParameterMatcher = regexp.MustCompile(`^Parameter \'([\w\]\[\_\-]+)\' is missing$`)
var (
// ErrMissingCredentials will be returned if no API key was provided to the API.
ErrMissingCredentials = NewAPIError(`apiKey is missing`)
// ErrInvalidCredentials will be returned if provided API key is invalid.
ErrInvalidCredentials = NewAPIError(`wrong "apiKey" value`)
// ErrAccessDenied will be returned in case of "Access denied" error.
ErrAccessDenied = NewAPIError("access denied")
// ErrAccountDoesNotExist will be returned if target system does not exist.
ErrAccountDoesNotExist = NewAPIError("account does not exist")
// ErrValidation will be returned in case of validation errors.
ErrValidation = NewAPIError("validation error")
// ErrMissingParameter will be returned if parameter is missing.
// Underlying error messages list will contain parameter name in the "Name" key.
ErrMissingParameter = NewAPIError("missing parameter")
// ErrGeneric will be returned if error cannot be classified as one of the errors above.
ErrGeneric = NewAPIError("API error")
)
// APIErrorsList struct.
type APIErrorsList map[string]string
// APIError returns when an API error was occurred.
type APIError interface {
error
fmt.Stringer
withWrapped(error) APIError
withErrors(APIErrorsList) APIError
Unwrap() error
Errors() APIErrorsList
}
type apiError struct {
ErrorMsg string `json:"errorMsg,omitempty"`
ErrorsList APIErrorsList `json:"errors,omitempty"`
wrapped error
}
// CreateAPIError from the provided response data. Different error types will be returned depending on the response,
// all of them can be matched using errors.Is. APi errors will always implement APIError interface.
func CreateAPIError(dataResponse []byte) error {
a := &apiError{}
if len(dataResponse) > 0 && dataResponse[0] == '<' {
return ErrAccountDoesNotExist
}
if err := json.Unmarshal(dataResponse, &a); err != nil {
return err
}
var found APIError
switch a.ErrorMsg {
case `"apiKey" is missing.`:
found = ErrMissingCredentials
case `Wrong "apiKey" value.`:
found = ErrInvalidCredentials
case "Access denied.":
found = ErrAccessDenied
case "Account does not exist.":
found = ErrAccountDoesNotExist
case "Errors in the entity format":
fallthrough
case "Validation error":
found = ErrValidation
default:
if param, ok := asMissingParameterErr(a.ErrorMsg); ok {
return a.withWrapped(ErrMissingParameter).withErrors(APIErrorsList{"Name": param})
}
found = ErrGeneric
}
result := NewAPIError(a.ErrorMsg).withWrapped(found)
if len(a.ErrorsList) > 0 {
return result.withErrors(a.ErrorsList)
}
return result
}
// CreateGenericAPIError for the situations when API response cannot be processed, but response was actually received.
func CreateGenericAPIError(message string) APIError {
return NewAPIError(message).withWrapped(ErrGeneric)
}
// NewAPIError returns API error with the provided message.
func NewAPIError(message string) APIError {
return &apiError{ErrorMsg: message}
}
// AsAPIError returns APIError and true if provided error is an APIError or contains wrapped APIError.
// Returns (nil, false) otherwise.
func AsAPIError(err error) (APIError, bool) {
apiErr := unwrapAPIError(err)
return apiErr, apiErr != nil
}
func unwrapAPIError(err error) APIError {
if err == nil {
return nil
}
if apiErr, ok := err.(APIError); ok { // nolint:errorlint
return apiErr
}
wrapper, ok := err.(interface { // nolint:errorlint
Unwrap() error
})
if ok {
return unwrapAPIError(wrapper.Unwrap())
}
return nil
}
// asMissingParameterErr returns true if "Parameter 'name' is missing" error message is provided.
func asMissingParameterErr(message string) (string, bool) {
matches := missingParameterMatcher.FindAllStringSubmatch(message, -1)
if len(matches) == 1 && len(matches[0]) == 2 {
return matches[0][1], true
}
return "", false
}
// Error returns errorMsg field from the response.
func (e *apiError) Error() string {
return e.ErrorMsg
}
// Unwrap returns wrapped error. It is usually one of the predefined types like ErrGeneric or ErrValidation.
// It can be used directly, but it's main purpose is to make errors matchable via errors.Is call.
func (e *apiError) Unwrap() error {
return e.wrapped
}
// Errors returns errors field from the response.
func (e *apiError) Errors() APIErrorsList {
return e.ErrorsList
}
// String returns string representation of an APIError.
func (e *apiError) String() string {
var sb strings.Builder
sb.Grow(256) // nolint:gomnd
sb.WriteString(fmt.Sprintf(`errorMsg: "%s"`, e.Error()))
if len(e.Errors()) > 0 {
i := 0
useIndex := true
errorList := make([]string, len(e.Errors()))
for index, errText := range e.Errors() {
if i == 0 && index == "0" {
useIndex = false
}
if useIndex {
errorList[i] = fmt.Sprintf(`%s: "%s"`, index, errText)
} else {
errorList[i] = errText
}
i++
}
sb.WriteString(", errors: [" + strings.Join(errorList, ", ") + "]")
}
return sb.String()
}
// withWrapped is a wrapped setter.
func (e *apiError) withWrapped(err error) APIError {
e.wrapped = err
return e
}
// withErrors is an ErrorsList setter.
func (e *apiError) withErrors(m APIErrorsList) APIError {
e.ErrorsList = m
return e
}

171
error_test.go Normal file
View File

@ -0,0 +1,171 @@
package retailcrm
import (
"testing"
"github.com/stretchr/testify/suite"
)
type ErrorTest struct {
suite.Suite
}
func TestError(t *testing.T) {
suite.Run(t, new(ErrorTest))
}
func (t *ErrorTest) TestFailure_ApiErrorsSlice() {
b := []byte(`{"success": false,
"errorMsg": "Failed to activate module",
"errors": [
"Your account has insufficient funds to activate integration module",
"Test error"
]}`)
expected := APIErrorsList{
"0": "Your account has insufficient funds to activate integration module",
"1": "Test error",
}
e := CreateAPIError(b)
apiErr, ok := AsAPIError(e)
t.Require().ErrorIs(e, ErrGeneric)
t.Require().NotNil(apiErr)
t.Require().True(ok)
t.Assert().Equal(expected, apiErr.Errors())
}
func (t *ErrorTest) TestFailure_ApiErrorsMap() {
b := []byte(`{"success": false,
"errorMsg": "Failed to activate module",
"errors": {"id": "ID must be an integer", "test": "Test error"}}`,
)
expected := APIErrorsList{
"id": "ID must be an integer",
"test": "Test error",
}
e := CreateAPIError(b)
apiErr, ok := AsAPIError(e)
t.Require().ErrorIs(e, ErrGeneric)
t.Require().NotNil(apiErr)
t.Require().True(ok)
t.Assert().Equal(expected, apiErr.Errors())
}
func (t *ErrorTest) TestFailure_APIKeyMissing() {
b := []byte(`{"success": false,
"errorMsg": "\"apiKey\" is missing."}`,
)
e := CreateAPIError(b)
apiErr, ok := AsAPIError(e)
t.Require().NotNil(apiErr)
t.Require().True(ok)
t.Require().ErrorIs(e, ErrMissingCredentials)
}
func (t *ErrorTest) TestFailure_APIKeyWrong() {
b := []byte(`{"success": false,
"errorMsg": "Wrong \"apiKey\" value."}`,
)
e := CreateAPIError(b)
apiErr, ok := AsAPIError(e)
t.Require().NotNil(apiErr)
t.Require().True(ok)
t.Require().ErrorIs(e, ErrInvalidCredentials)
}
func (t *ErrorTest) TestFailure_AccessDenied() {
b := []byte(`{"success": false,
"errorMsg": "Access denied."}`,
)
e := CreateAPIError(b)
apiErr, ok := AsAPIError(e)
t.Require().NotNil(apiErr)
t.Require().True(ok)
t.Require().ErrorIs(e, ErrAccessDenied)
}
func (t *ErrorTest) TestFailure_AccountDoesNotExist() {
b := []byte(`{"success": false,
"errorMsg": "Account does not exist."}`,
)
e := CreateAPIError(b)
apiErr, ok := AsAPIError(e)
t.Require().NotNil(apiErr)
t.Require().True(ok)
t.Require().ErrorIs(e, ErrAccountDoesNotExist)
}
func (t *ErrorTest) TestFailure_Validation() {
b := []byte(`{"success": false,
"errorMsg": "Errors in the entity format",
"errors": {"name": "name must be provided"}}`,
)
e := CreateAPIError(b)
apiErr, ok := AsAPIError(e)
t.Require().NotNil(apiErr)
t.Require().True(ok)
t.Require().ErrorIs(e, ErrValidation)
t.Assert().Equal("name must be provided", apiErr.Errors()["name"])
}
func (t *ErrorTest) TestFailure_Validation2() {
b := []byte(`{"success": false,
"errorMsg": "Validation error",
"errors": {"name": "name must be provided"}}`,
)
e := CreateAPIError(b)
apiErr, ok := AsAPIError(e)
t.Require().NotNil(apiErr)
t.Require().True(ok)
t.Require().ErrorIs(e, ErrValidation)
t.Assert().Equal("name must be provided", apiErr.Errors()["name"])
t.Assert().Equal("errorMsg: \"Validation error\", errors: [name: \"name must be provided\"]", apiErr.String())
}
func (t *ErrorTest) TestFailure_MissingParameter() {
b := []byte(`{"success": false,
"errorMsg": "Parameter 'item' is missing"}`,
)
e := CreateAPIError(b)
apiErr, ok := AsAPIError(e)
t.Require().NotNil(apiErr)
t.Require().True(ok)
t.Require().ErrorIs(e, ErrMissingParameter)
t.Assert().Equal("item", apiErr.Errors()["Name"])
}
func (t *ErrorTest) Test_CreateGenericAPIError() {
e := CreateGenericAPIError("generic error message")
apiErr, ok := AsAPIError(e)
t.Require().NotNil(apiErr)
t.Require().True(ok)
t.Assert().ErrorIs(apiErr, ErrGeneric)
t.Assert().Equal("generic error message", e.Error())
}
func (t *ErrorTest) TestFailure_HTML() {
e := CreateAPIError([]byte{'<'})
apiErr, ok := AsAPIError(e)
t.Require().NotNil(apiErr)
t.Require().True(ok)
t.Assert().ErrorIs(apiErr, ErrAccountDoesNotExist)
}

View File

@ -1,4 +1,4 @@
package v5
package retailcrm
// CustomersFilter type.
type CustomersFilter struct {

6
go.mod
View File

@ -1,12 +1,10 @@
module github.com/retailcrm/api-client-go
module github.com/retailcrm/api-client-go/v2
go 1.13
require (
github.com/google/go-querystring v1.1.0
github.com/joho/godotenv v1.3.0
golang.org/x/net v0.0.0-20191021144547-ec77196f6094 // indirect
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543
gopkg.in/h2non/gentleman.v1 v1.0.4 // indirect
github.com/stretchr/testify v1.7.0
gopkg.in/h2non/gock.v1 v1.1.2
)

18
go.sum
View File

@ -1,3 +1,5 @@
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
@ -8,14 +10,16 @@ github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4=
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20191021144547-ec77196f6094/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/h2non/gentleman.v1 v1.0.4/go.mod h1:JYuHVdFzS4MKOXe0o+chKJ4hCe6tqKKw9XH9YP6WFrg=
gopkg.in/h2non/gock.v1 v1.0.16 h1:F11k+OafeuFENsjei5t2vMTSTs9L62AdyTe4E1cgdG8=
gopkg.in/h2non/gock.v1 v1.0.16/go.mod h1:XVuDAssexPLwgxCLMvDTWNU5eqklsydR6I5phZ9oPB8=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

25
log.go Normal file
View File

@ -0,0 +1,25 @@
package retailcrm
// BasicLogger provides basic functionality for logging.
type BasicLogger interface {
Printf(string, ...interface{})
}
// DebugLogger can be used to easily wrap any logger with Debugf method into the BasicLogger instance.
type DebugLogger interface {
Debugf(string, ...interface{})
}
type debugLoggerAdapter struct {
logger DebugLogger
}
// DebugLoggerAdapter returns BasicLogger that calls underlying DebugLogger.Debugf.
func DebugLoggerAdapter(logger DebugLogger) BasicLogger {
return &debugLoggerAdapter{logger: logger}
}
// Printf data in the log using DebugLogger.Debugf.
func (l *debugLoggerAdapter) Printf(format string, v ...interface{}) {
l.logger.Debugf(format, v...)
}

24
log_test.go Normal file
View File

@ -0,0 +1,24 @@
package retailcrm
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
type wrappedLogger struct {
lastMessage string
}
func (w *wrappedLogger) Debugf(msg string, v ...interface{}) {
w.lastMessage = fmt.Sprintf(msg, v...)
}
func TestDebugLoggerAdapter_Printf(t *testing.T) {
wrapped := &wrappedLogger{}
logger := DebugLoggerAdapter(wrapped)
logger.Printf("Test message #%d", 1)
assert.Equal(t, "Test message #1", wrapped.lastMessage)
}

102
marshaling.go Normal file
View File

@ -0,0 +1,102 @@
package retailcrm
import (
"encoding/json"
"fmt"
"strconv"
)
func (t Tag) MarshalJSON() ([]byte, error) {
return json.Marshal(t.Name)
}
func (a *APIErrorsList) UnmarshalJSON(data []byte) error {
var i interface{}
var m APIErrorsList
if err := json.Unmarshal(data, &i); err != nil {
return err
}
switch e := i.(type) {
case map[string]interface{}:
m = make(APIErrorsList, len(e))
for idx, val := range e {
m[idx] = fmt.Sprint(val)
}
case []interface{}:
m = make(APIErrorsList, len(e))
for idx, val := range e {
m[strconv.Itoa(idx)] = fmt.Sprint(val)
}
}
*a = m
return nil
}
func (l *CustomFieldsList) UnmarshalJSON(data []byte) error {
var i interface{}
var m CustomFieldsList
if err := json.Unmarshal(data, &i); err != nil {
return err
}
switch e := i.(type) {
case map[string]interface{}:
m = make(CustomFieldsList, len(e))
for idx, val := range e {
m[idx] = fmt.Sprint(val)
}
case []interface{}:
m = make(CustomFieldsList, len(e))
for idx, val := range e {
m[strconv.Itoa(idx)] = fmt.Sprint(val)
}
}
*l = m
return nil
}
func (p *OrderPayments) UnmarshalJSON(data []byte) error {
var i interface{}
var m OrderPayments
if err := json.Unmarshal(data, &i); err != nil {
return err
}
switch e := i.(type) {
case map[string]interface{}:
m = make(OrderPayments, len(e))
for idx, val := range e {
var res OrderPayment
err := unmarshalMap(val.(map[string]interface{}), &res)
if err != nil {
return err
}
m[idx] = res
}
case []interface{}:
m = make(OrderPayments, len(e))
for idx, val := range e {
var res OrderPayment
err := unmarshalMap(val.(map[string]interface{}), &res)
if err != nil {
return err
}
m[strconv.Itoa(idx)] = res
}
}
*p = m
return nil
}
func unmarshalMap(m map[string]interface{}, v interface{}) (err error) {
var data []byte
data, err = json.Marshal(m)
if err != nil {
return err
}
return json.Unmarshal(data, v)
}

69
marshaling_test.go Normal file
View File

@ -0,0 +1,69 @@
package retailcrm
import (
"encoding/json"
"reflect"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTag_MarshalJSON(t *testing.T) {
tags := []Tag{
{"first", "#3e89b6", false},
{"second", "#ffa654", false},
}
names := []byte(`["first","second"]`)
str, err := json.Marshal(tags)
if err != nil {
t.Errorf("%v", err.Error())
}
if !reflect.DeepEqual(str, names) {
t.Errorf("Marshaled: %#v\nExpected: %#v\n", str, names)
}
}
func TestAPIErrorsList_UnmarshalJSON(t *testing.T) {
var list APIErrorsList
require.NoError(t, json.Unmarshal([]byte(`["first", "second"]`), &list))
assert.Len(t, list, 2)
assert.Equal(t, list["0"], "first")
assert.Equal(t, list["1"], "second")
require.NoError(t, json.Unmarshal([]byte(`{"a": "first", "b": "second"}`), &list))
assert.Len(t, list, 2)
assert.Equal(t, list["a"], "first")
assert.Equal(t, list["b"], "second")
}
func TestCustomFieldsList_UnmarshalJSON(t *testing.T) {
var list CustomFieldsList
require.NoError(t, json.Unmarshal([]byte(`["first", "second"]`), &list))
assert.Len(t, list, 2)
assert.Equal(t, list["0"], "first")
assert.Equal(t, list["1"], "second")
require.NoError(t, json.Unmarshal([]byte(`{"a": "first", "b": "second"}`), &list))
assert.Len(t, list, 2)
assert.Equal(t, list["a"], "first")
assert.Equal(t, list["b"], "second")
}
func TestOrderPayments_UnmarshalJSON(t *testing.T) {
var list OrderPayments
require.NoError(t, json.Unmarshal([]byte(`[{"id": 1}, {"id": 2}]`), &list))
assert.Len(t, list, 2)
assert.Equal(t, list["0"], OrderPayment{ID: 1})
assert.Equal(t, list["1"], OrderPayment{ID: 2})
require.NoError(t, json.Unmarshal([]byte(`{"a": {"id": 1}, "b": {"id": 2}}`), &list))
assert.Len(t, list, 2)
assert.Equal(t, list["a"], OrderPayment{ID: 1})
assert.Equal(t, list["b"], OrderPayment{ID: 2})
}

View File

@ -1,4 +1,4 @@
package v5
package retailcrm
// CustomerRequest type.
type CustomerRequest struct {
@ -177,8 +177,8 @@ type ProductsPropertiesRequest struct {
type DeliveryTrackingRequest struct {
DeliveryID string `json:"deliveryId,omitempty"`
TrackNumber string `json:"trackNumber,omitempty"`
History []DeliveryHistoryRecord `json:"history,omitempty,brackets"`
ExtraData map[string]string `json:"extraData,omitempty,brackets"`
History []DeliveryHistoryRecord `json:"history,omitempty"`
ExtraData map[string]string `json:"extraData,omitempty"`
}
// DeliveryShipmentsRequest type.

View File

@ -1,4 +1,4 @@
package v5
package retailcrm
// SuccessfulResponse type.
type SuccessfulResponse struct {
@ -20,7 +20,7 @@ type OrderCreateResponse struct {
// OperationResponse type.
type OperationResponse struct {
Success bool `json:"success"`
Errors map[string]string `json:"errors,omitempty"`
Errors map[string]string `json:"ErrorsList,omitempty"`
}
// VersionResponse return available API versions.
@ -409,7 +409,7 @@ type ResponseInfo struct {
// MgInfo type.
type MgInfo struct {
EndpointUrl string `json:"endpointUrl"`
EndpointURL string `json:"endpointUrl"`
Token string `json:"token"`
}

View File

@ -1,4 +1,4 @@
package v5
package retailcrm
import (
"encoding/json"
@ -19,6 +19,7 @@ type Client struct {
Key string
Debug bool
httpClient *http.Client
logger BasicLogger
}
// Pagination type.
@ -154,7 +155,7 @@ type Customer struct {
BrowserID string `json:"browserId,omitempty"`
MgCustomerID string `json:"mgCustomerId,omitempty"`
PhotoURL string `json:"photoUrl,omitempty"`
CustomFields map[string]string `json:"customFields,omitempty"`
CustomFields CustomFieldsList `json:"customFields,omitempty"`
Tags []Tag `json:"tags,omitempty"`
}
@ -166,7 +167,7 @@ type CorporateCustomer struct {
CreatedAt string `json:"createdAt,omitempty"`
Vip bool `json:"vip,omitempty"`
Bad bool `json:"bad,omitempty"`
CustomFields map[string]string `json:"customFields,omitempty"`
CustomFields CustomFieldsList `json:"customFields,omitempty"`
PersonalDiscount float32 `json:"personalDiscount,omitempty"`
DiscountCardNumber string `json:"discountCardNumber,omitempty"`
ManagerID int `json:"managerId,omitempty"`
@ -227,7 +228,7 @@ type Company struct {
CreatedAt string `json:"createdAt,omitempty"`
Contragent *Contragent `json:"contragent,omitempty"`
Address *IdentifiersPair `json:"address,omitempty"`
CustomFields map[string]string `json:"customFields,omitempty"`
CustomFields CustomFieldsList `json:"customFields,omitempty"`
}
// CorporateCustomerNote type.
@ -272,6 +273,9 @@ type CorporateCustomerHistoryRecord struct {
Order related types
*/
type OrderPayments map[string]OrderPayment
type CustomFieldsList map[string]string
// Order type.
type Order struct {
ID int `json:"id,omitempty"`
@ -321,8 +325,8 @@ type Order struct {
Delivery *OrderDelivery `json:"delivery,omitempty"`
Marketplace *OrderMarketplace `json:"marketplace,omitempty"`
Items []OrderItem `json:"items,omitempty"`
CustomFields map[string]string `json:"customFields,omitempty"`
Payments map[string]OrderPayment `json:"payments,omitempty"`
CustomFields CustomFieldsList `json:"customFields,omitempty"`
Payments OrderPayments `json:"payments,omitempty"`
}
// OrdersStatus type.
@ -378,8 +382,14 @@ type OrderDeliveryData struct {
// UnmarshalJSON method.
func (v *OrderDeliveryData) UnmarshalJSON(b []byte) error {
var additionalData map[string]interface{}
json.Unmarshal(b, &additionalData)
json.Unmarshal(b, &v.OrderDeliveryDataBasic)
err := json.Unmarshal(b, &additionalData)
if err != nil {
return err
}
err = json.Unmarshal(b, &v.OrderDeliveryDataBasic)
if err != nil {
return err
}
object := reflect.TypeOf(v.OrderDeliveryDataBasic)
for i := 0; i < object.NumField(); i++ {
@ -401,7 +411,10 @@ func (v *OrderDeliveryData) UnmarshalJSON(b []byte) error {
func (v OrderDeliveryData) MarshalJSON() ([]byte, error) {
result := map[string]interface{}{}
data, _ := json.Marshal(v.OrderDeliveryDataBasic)
json.Unmarshal(data, &result)
err := json.Unmarshal(data, &result)
if err != nil {
return nil, err
}
for key, value := range v.AdditionalFields {
result[key] = value
@ -587,7 +600,7 @@ type User struct {
Phone string `json:"phone,omitempty"`
Status string `json:"status,omitempty"`
Groups []UserGroup `json:"groups,omitempty"`
MgUserId uint64 `json:"mgUserId,omitempty"`
MGUserID uint64 `json:"mgUserId,omitempty"`
}
// UserGroup type.
@ -1054,7 +1067,7 @@ type Action struct {
// MgTransport type.
type MgTransport struct {
WebhookUrl string `json:"webhookUrl,omitempty"`
WebhookURL string `json:"webhookUrl,omitempty"`
}
// MgBot type.
@ -1072,7 +1085,7 @@ type CostRecord struct {
DateTo string `json:"dateTo,omitempty"`
Summ float32 `json:"summ,omitempty"`
CostItem string `json:"costItem,omitempty"`
UserId int `json:"userId,omitempty"`
UserID int `json:"userId,omitempty"`
Order *Order `json:"order,omitempty"`
Sites []string `json:"sites,omitempty"`
}
@ -1089,7 +1102,7 @@ type Cost struct {
CreatedAt string `json:"createdAt,omitempty"`
CreatedBy string `json:"createdBy,omitempty"`
Order *Order `json:"order,omitempty"`
UserId int `json:"userId,omitempty"`
UserID int `json:"userId,omitempty"`
Sites []string `json:"sites,omitempty"`
}

View File

@ -1,4 +1,4 @@
package v5
package retailcrm
import (
"encoding/json"

View File

@ -1,32 +0,0 @@
package v5
import "encoding/json"
// APIErrorsList struct.
type APIErrorsList map[string]string
// APIError struct.
type APIError struct {
SuccessfulResponse
ErrorMsg string `json:"errorMsg,omitempty"`
Errors APIErrorsList `json:"errors,omitempty"`
}
func (e *APIError) Error() string {
return e.ErrorMsg
}
func NewAPIError(dataResponse []byte) error {
a := &APIError{}
if len(dataResponse) > 0 && dataResponse[0] == '<' {
a.ErrorMsg = "Account does not exist."
return a
}
if err := json.Unmarshal(dataResponse, a); err != nil {
return err
}
return a
}

View File

@ -1,54 +0,0 @@
package v5
import (
"reflect"
"testing"
"golang.org/x/xerrors"
)
func TestFailure_ApiErrorsSlice(t *testing.T) {
b := []byte(`{"success": false,
"errorMsg": "Failed to activate module",
"errors": [
"Your account has insufficient funds to activate integration module",
"Test error"
]}`)
expected := APIErrorsList{
"0": "Your account has insufficient funds to activate integration module",
"1": "Test error",
}
var expEr *APIError
e := NewAPIError(b)
if xerrors.As(e, &expEr) {
if eq := reflect.DeepEqual(expEr.Errors, expected); eq != true {
t.Errorf("%+v", eq)
}
} else {
t.Errorf("Error must be type of APIError: %v", e)
}
}
func TestFailure_ApiErrorsMap(t *testing.T) {
b := []byte(`{"success": false,
"errorMsg": "Failed to activate module",
"errors": {"id": "ID must be an integer", "test": "Test error"}}`,
)
expected := APIErrorsList{
"id": "ID must be an integer",
"test": "Test error",
}
var expEr *APIError
e := NewAPIError(b)
if xerrors.As(e, &expEr) {
if eq := reflect.DeepEqual(expEr.Errors, expected); eq != true {
t.Errorf("%+v", eq)
}
} else {
t.Errorf("Error must be type of APIError: %v", e)
}
}

View File

@ -1,35 +0,0 @@
package v5
import (
"encoding/json"
"fmt"
"strconv"
)
func (t Tag) MarshalJSON() ([]byte, error) {
return json.Marshal(t.Name)
}
func (a *APIErrorsList) UnmarshalJSON(data []byte) error {
var i interface{}
var m map[string]string
if err := json.Unmarshal(data, &i); err != nil {
return err
}
switch e := i.(type) {
case map[string]interface{}:
m = make(map[string]string, len(e))
for idx, val := range e {
m[idx] = fmt.Sprint(val)
}
case []interface{}:
m = make(map[string]string, len(e))
for idx, val := range e {
m[strconv.Itoa(idx)] = fmt.Sprint(val)
}
}
*a = m
return nil
}

View File

@ -1,24 +0,0 @@
package v5
import (
"encoding/json"
"reflect"
"testing"
)
func TestTag_MarshalJSON(t *testing.T) {
tags := []Tag{
{"first", "#3e89b6", false},
{"second", "#ffa654", false},
}
names := []byte(`["first","second"]`)
str, err := json.Marshal(tags)
if err != nil {
t.Errorf("%v", err.Error())
}
if !reflect.DeepEqual(str, names) {
t.Errorf("Marshaled: %#v\nExpected: %#v\n", str, names)
}
}