From 2f06836bd4a5e8d472f99bf5af261e75876d24e2 Mon Sep 17 00:00:00 2001 From: Pavel Date: Wed, 4 Sep 2019 11:38:21 +0300 Subject: [PATCH 1/4] files upload support --- v5/client.go | 262 +++++++++++++++++++++++++++++++++++++++-- v5/client_test.go | 293 ++++++++++++++++++++++++++++++++++++++++++++++ v5/filters.go | 17 +++ v5/request.go | 7 ++ v5/response.go | 23 +++- v5/types.go | 16 +++ 6 files changed, 609 insertions(+), 9 deletions(-) diff --git a/v5/client.go b/v5/client.go index 7cfe551..06fd460 100644 --- a/v5/client.go +++ b/v5/client.go @@ -1,8 +1,10 @@ package v5 import ( + "bytes" "encoding/json" "fmt" + "io" "io/ioutil" "log" "net/http" @@ -12,6 +14,7 @@ import ( "time" "github.com/google/go-querystring/query" + "github.com/pkg/errors" "github.com/retailcrm/api-client-go/errs" ) @@ -73,23 +76,46 @@ func (c *Client) GetRequest(urlWithParameters string, versioned ...bool) ([]byte return res, resp.StatusCode, failure } -// PostRequest implements POST Request -func (c *Client) PostRequest(url string, postParams url.Values) ([]byte, int, *errs.Failure) { - var res []byte - var prefix = "/api/v5" +// PostRequest implements POST Request with generic body data +func (c *Client) PostRequest(uri string, postData interface{}, contType ...string) ([]byte, int, *errs.Failure) { + var ( + res []byte + contentType string + reader io.Reader + ) + + prefix := "/api/v5" failure := &errs.Failure{} - req, err := http.NewRequest("POST", fmt.Sprintf("%s%s%s", c.URL, prefix, url), strings.NewReader(postParams.Encode())) + if len(contType) > 0 { + contentType = contType[0] + } else { + contentType = "application/x-www-form-urlencoded" + } + + switch postData.(type) { + case url.Values: + reader = strings.NewReader(postData.(url.Values).Encode()) + default: + if i, ok := postData.(io.Reader); ok { + reader = i + } else { + failure.SetRuntimeError(errors.New("postData should be url.Values or implement io.Reader")) + return []byte{}, 0, failure + } + } + + req, err := http.NewRequest("POST", fmt.Sprintf("%s%s%s", c.URL, prefix, uri), reader) if err != nil { failure.SetRuntimeError(err) return res, 0, failure } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Content-Type", contentType) req.Header.Set("X-API-KEY", c.Key) if c.Debug { - log.Printf("API Request: %s %s %s", url, c.Key, postParams.Encode()) + log.Printf("API Request: %s %s", uri, c.Key) } resp, err := c.httpClient.Do(req) @@ -3941,6 +3967,228 @@ func (c *Client) CostEdit(id int, cost CostRecord, site ...string) (CreateRespon return resp, status, nil } +// Files returns files list +// +// For more information see https://help.retailcrm.pro/Developers/ApiVersion5#get--api-v5-files +// +// Example: +// +// var client = v5.New("https://demo.url", "09jIJ") +// +// data, status, err := client.Files(FilesRequest{ +// Filter: FilesFilter{ +// Filename: "image.jpeg", +// }, +// }) +// +// if err.Error() != "" { +// fmt.Printf("%v", err.RuntimeErr) +// } +// +// if status >= http.StatusBadRequest { +// fmt.Printf("%v", err.ApiErr()) +// } +func (c *Client) Files(files FilesRequest) (FilesResponse, int, *errs.Failure) { + var resp FilesResponse + + params, _ := query.Values(files) + + data, status, err := c.GetRequest(fmt.Sprintf("/files?%s", params.Encode())) + + if err != nil && err.Error() != "" { + return resp, status, err + } + + json.Unmarshal(data, &resp) + + if resp.Success == false { + buildErr(data, err) + return resp, status, err + } + + return resp, status, nil +} + +// FileUpload uploads file to retailCRM +// +// For more information see https://help.retailcrm.pro/Developers/ApiVersion5#get--api-v5-files +// +// Example: +// +// var client = v5.New("https://demo.url", "09jIJ") +// +// file, err := os.Open("file.jpg") +// if err != nil { +// fmt.Print(err) +// } +// +// data, status, err := client.FileUpload(file) +// +// if err.Error() != "" { +// fmt.Printf("%v", err.RuntimeErr) +// } +// +// if status >= http.StatusBadRequest { +// fmt.Printf("%v", err.ApiErr()) +// } +func (c *Client) FileUpload(reader io.Reader) (FileUploadResponse, int, *errs.Failure) { + var resp FileUploadResponse + + data, status, err := c.PostRequest(fmt.Sprintf("/files/upload?apiKey=%s", c.Key), reader, "application/octet-stream") + + if err != nil && err.Error() != "" { + return resp, status, err + } + + json.Unmarshal(data, &resp) + + if resp.Success == false { + buildErr(data, err) + return resp, status, err + } + + return resp, status, nil +} + +// File returns a file info +// +// For more information see https://help.retailcrm.pro/Developers/ApiVersion5#get--api-v5-files +// +// Example: +// +// var client = v5.New("https://demo.url", "09jIJ") +// +// data, status, err := client.File(112) +// +// if err.Error() != "" { +// fmt.Printf("%v", err.RuntimeErr) +// } +// +// if status >= http.StatusBadRequest { +// fmt.Printf("%v", err.ApiErr()) +// } +// +// if data.Success == true { +// fmt.Printf("%v\n", data.File) +// } +func (c *Client) File(id int) (FileResponse, int, *errs.Failure) { + var resp FileResponse + + data, status, err := c.GetRequest(fmt.Sprintf("/files/%d", id)) + if err.Error() != "" { + return resp, status, err + } + + json.Unmarshal(data, &resp) + + if resp.Success == false { + buildErr(data, err) + return resp, status, err + } + + return resp, status, nil +} + +// FileDelete removes file from retailCRM +// +// For more information see https://help.retailcrm.pro/Developers/ApiVersion5#get--api-v5-files +// +// Example: +// +// var client = v5.New("https://demo.url", "09jIJ") +// +// data, status, err := client.FileDelete(123) +// +// if err.Error() != "" { +// fmt.Printf("%v", err.RuntimeErr) +// } +// +// if status >= http.StatusBadRequest { +// fmt.Printf("%v", err.ApiErr()) +// } +func (c *Client) FileDelete(id int) (SuccessfulResponse, int, *errs.Failure) { + var resp SuccessfulResponse + + data, status, err := c.PostRequest(fmt.Sprintf("/files/%d/delete", id), strings.NewReader("")) + + if err != nil && err.Error() != "" { + return resp, status, err + } + + json.Unmarshal(data, &resp) + + if resp.Success == false { + buildErr(data, err) + return resp, status, err + } + + return resp, status, nil +} + +// FileDownload downloads file from retailCRM +// +// For more information see https://help.retailcrm.pro/Developers/ApiVersion5#get--api-v5-files +// +// Example: +// +// var client = v5.New("https://demo.url", "09jIJ") +// +// fileData, status, err := client.FileDownload(123) +// +// if err.Error() != "" { +// fmt.Printf("%v", err.RuntimeErr) +// } +// +// if status >= http.StatusBadRequest { +// fmt.Printf("%v", err.ApiErr()) +// } +func (c *Client) FileDownload(id int) (io.ReadCloser, int, *errs.Failure) { + data, status, err := c.GetRequest(fmt.Sprintf("/files/%d/download", id)) + if status != http.StatusOK { + return nil, status, err + } + + closer := ioutil.NopCloser(bytes.NewReader(data)) + return closer, status, nil +} + +// FileEdit edits file name and relations with orders and customers in retailCRM +// +// For more information see https://help.retailcrm.pro/Developers/ApiVersion5#get--api-v5-files +// +// Example: +// +// var client = v5.New("https://demo.url", "09jIJ") +// +// data, status, err := client.FileEdit(123, File{Filename: "image2.jpg"}) +// +// if err.Error() != "" { +// fmt.Printf("%v", err.RuntimeErr) +// } +// +// if status >= http.StatusBadRequest { +// fmt.Printf("%v", err.ApiErr()) +// } +func (c *Client) FileEdit(id int, file File) (FileResponse, int, *errs.Failure) { + var resp FileResponse + + req, _ := json.Marshal(file) + data, status, err := c.PostRequest(fmt.Sprintf("/files/%d/edit", id), bytes.NewReader(req)) + + if err != nil && err.Error() != "" { + return resp, status, err + } + + json.Unmarshal(data, &resp) + + if resp.Success == false { + buildErr(data, err) + return resp, status, err + } + + return resp, status, nil +} + // CustomFields returns list of custom fields // // For more information see http://www.retailcrm.pro/docs/Developers/ApiVersion5#get--api-v5-custom-fields diff --git a/v5/client_test.go b/v5/client_test.go index 591ca2d..e6ed9a7 100644 --- a/v5/client_test.go +++ b/v5/client_test.go @@ -3,12 +3,14 @@ package v5 import ( "encoding/json" "fmt" + "io/ioutil" "log" "math/rand" "net/http" "net/url" "os" "strconv" + "strings" "testing" "time" @@ -4750,6 +4752,297 @@ func TestClient_CostsUpload_Fail(t *testing.T) { } } +func TestClient_Files(t *testing.T) { + c := client() + fileID := 14925 + + defer gock.Off() + + gock.New(crmURL). + Get("/files"). + MatchParam("filter[ids][]", strconv.Itoa(fileID)). + MatchParam("limit", "20"). + MatchParam("page", "1"). + Reply(200). + BodyString(`{ + "success": true, + "pagination": { + "limit": 20, + "totalCount": 0, + "currentPage": 1, + "totalPageCount": 0 + }, + "files": [] + }`) + + _, status, err := c.Files(FilesRequest{ + Limit: 20, + Page: 1, + Filter: FilesFilter{ + Ids: []int{fileID}, + }, + }) + + if status != 200 { + t.Errorf("%v %v", err.Error(), err.ApiError()) + } +} + +func TestClient_FileUpload(t *testing.T) { + c := client() + file := strings.NewReader(`test file contents`) + + defer gock.Off() + + gock.New(crmURL). + Post("/files/upload"). + Reply(200). + BodyString(`{"success": true, "file": {"id": 1}}`) + + data, status, err := c.FileUpload(file) + if err.Error() != "" { + t.Errorf("%v", err.Error()) + } + + if status != http.StatusOK { + t.Errorf("%v", err.ApiError()) + } + + if data.Success != true { + t.Errorf("%v", err.ApiError()) + } + + if data.File.ID != 1 { + t.Error("invalid file id") + } +} + +func TestClient_FileUploadFail(t *testing.T) { + c := client() + file := strings.NewReader(`test file contents`) + + defer gock.Off() + + gock.New(crmURL). + Post("/files/upload"). + Reply(400). + BodyString(`{"success": false, "errorMsg": "error"}`) + + _, status, err := c.FileUpload(file) + if err.Error() != "" { + t.Errorf("%v", err.Error()) + } + + if status != http.StatusBadRequest { + t.Errorf("status should be `%d`, got `%d` instead", http.StatusBadRequest, status) + } +} + +func TestClient_File(t *testing.T) { + c := client() + invalidFile := 20 + fileResponse := &FileResponse{ + Success: true, + File: &File{ + ID: 19, + Filename: "image.jpg", + Type: "image/jpeg", + CreatedAt: time.Now().String(), + Size: 10000, + Attachment: nil, + }, + } + respData, errr := json.Marshal(fileResponse) + if errr != nil { + t.Errorf("%v", errr.Error()) + } + + defer gock.Off() + + gock.New(crmURL). + Get(fmt.Sprintf("/files/%d", fileResponse.File.ID)). + Reply(200). + BodyString(string(respData)) + + gock.New(crmURL). + Get(fmt.Sprintf("/files/%d", invalidFile)). + Reply(404). + BodyString(`{"success": false, "errorMsg": "Not Found"}`) + + s, status, err := c.File(fileResponse.File.ID) + if err.Error() != "" { + t.Errorf("%v", err.Error()) + } + + if status != http.StatusOK { + t.Errorf("%v", err.ApiError()) + } + + if s.Success != true { + t.Errorf("%v", err.ApiError()) + } + + if s.File.ID != fileResponse.File.ID { + t.Error("invalid response data") + } + + s, status, err = c.File(invalidFile) + if err.Error() != "" { + t.Errorf("%v", err.Error()) + } + + if status != http.StatusNotFound { + t.Errorf("status should be `%d`, got `%d` instead", http.StatusNotFound, status) + } +} + +func TestClient_FileDelete(t *testing.T) { + c := client() + successful := 19 + badRequest := 20 + notFound := 21 + + defer gock.Off() + + gock.New(crmURL). + Post(fmt.Sprintf("/files/%d/delete", successful)). + Reply(200). + BodyString(`{"success": true}`) + + gock.New(crmURL). + Post(fmt.Sprintf("/files/%d/delete", badRequest)). + Reply(400). + BodyString(`{"success": false, "errorMsg": "Error"}`) + + gock.New(crmURL). + Post(fmt.Sprintf("/files/%d/delete", notFound)). + Reply(404). + BodyString(`{"success": false, "errorMsg": "Not Found"}`) + + data, status, err := c.FileDelete(successful) + if err.Error() != "" { + t.Errorf("%v", err.Error()) + } + + if status != http.StatusOK { + t.Errorf("%v", err.ApiError()) + } + + if data.Success != true { + t.Errorf("%v", err.ApiError()) + } + + data, status, err = c.FileDelete(badRequest) + if err.Error() != "" { + t.Errorf("%v", err.Error()) + } + + if status != http.StatusBadRequest { + t.Errorf("status should be `%d`, got `%d` instead", http.StatusBadRequest, status) + } + + data, status, err = c.FileDelete(notFound) + if err.Error() != "" { + t.Errorf("%v", err.Error()) + } + + if status != http.StatusNotFound { + t.Errorf("status should be `%d`, got `%d` instead", http.StatusNotFound, status) + } +} + +func TestClient_FileDownload(t *testing.T) { + c := client() + successful := 19 + fail := 20 + fileData := "file data" + + defer gock.Off() + + gock.New(crmURL). + Get(fmt.Sprintf("/files/%d/download", successful)). + Reply(200). + BodyString(fileData) + + gock.New(crmURL). + Get(fmt.Sprintf("/files/%d/download", fail)). + Reply(400). + BodyString("") + + data, status, err := c.FileDownload(successful) + if err.Error() != "" { + t.Errorf("%v", err.Error()) + } + + if status != http.StatusOK { + t.Errorf("%v", err.ApiError()) + } + + fetchedByte, errr := ioutil.ReadAll(data) + if errr != nil { + t.Error(errr) + } + + fetched := string(fetchedByte) + if fetched != fileData { + t.Error("file data mismatch") + } + + data, status, err = c.FileDownload(fail) + if err.Error() != "" { + t.Errorf("%v", err.Error()) + } + + if status != http.StatusBadRequest { + t.Errorf("status should be `%d`, got `%d` instead", http.StatusBadRequest, status) + } +} + +func TestClient_FileEdit(t *testing.T) { + c := client() + successful := 19 + fail := 20 + resp := FileResponse{ + Success: true, + File: &File{Filename: "image.jpg"}, + } + respData, _ := json.Marshal(resp) + + defer gock.Off() + + gock.New(crmURL). + Post(fmt.Sprintf("/files/%d/edit", successful)). + Reply(200). + BodyString(string(respData)) + + gock.New(crmURL). + Post(fmt.Sprintf("/files/%d/edit", fail)). + Reply(404). + BodyString(`{"success": false, "errorMsg": "Not Found"}`) + + data, status, err := c.FileEdit(successful, *resp.File) + if err.Error() != "" { + t.Errorf("%v", err.Error()) + } + + if status != http.StatusOK { + t.Errorf("%v", err.ApiError()) + } + + if data.Success != true { + t.Errorf("%v", err.ApiError()) + } + + if data.File.Filename != resp.File.Filename { + t.Errorf("filename should be `%s`, got `%s` instead", resp.File.Filename, data.File.Filename) + } + + data, status, err = c.FileEdit(fail, *resp.File) + if status != http.StatusNotFound { + t.Errorf("status should be `%d`, got `%d` instead", http.StatusNotFound, status) + } +} + func TestClient_CustomFields(t *testing.T) { c := client() diff --git a/v5/filters.go b/v5/filters.go index deedf0a..3e2fdf7 100644 --- a/v5/filters.go +++ b/v5/filters.go @@ -318,6 +318,23 @@ type CostsFilter struct { OrderExternalIds []string `url:"orderIds,omitempty,brackets"` } +// FilesFilter type +type FilesFilter struct { + Ids []int `url:"ids,omitempty,brackets"` + OrderIds []int `url:"orderIds,omitempty,brackets"` + OrderExternalIds []string `url:"orderExternalIds,omitempty,brackets"` + CustomerIds []int `url:"customerIds,omitempty,brackets"` + CustomerExternalIds []string `url:"customerExternalIds,omitempty,brackets"` + CreatedAtFrom string `url:"createdAtFrom,omitempty"` + CreatedAtTo string `url:"createdAtTo,omitempty"` + SizeFrom int `url:"sizeFrom,omitempty"` + SizeTo int `url:"sizeTo,omitempty"` + Type []string `url:"type,omitempty,brackets"` + Filename string `url:"filename,omitempty"` + IsAttached string `url:"isAttached,omitempty"` + Sites []string `url:"sites,omitempty,brackets"` +} + // CustomFieldsFilter type type CustomFieldsFilter struct { Name string `url:"name,omitempty"` diff --git a/v5/request.go b/v5/request.go index 6f53f07..9c78e2d 100644 --- a/v5/request.go +++ b/v5/request.go @@ -150,6 +150,13 @@ type CostsRequest struct { Page int `url:"page,omitempty"` } +// FilesRequest type +type FilesRequest struct { + Filter FilesFilter `url:"filter,omitempty"` + Limit int `url:"limit,omitempty"` + Page int `url:"page,omitempty"` +} + // CustomFieldsRequest type type CustomFieldsRequest struct { Filter CustomFieldsFilter `url:"filter,omitempty"` diff --git a/v5/response.go b/v5/response.go index f0c3457..72467c8 100644 --- a/v5/response.go +++ b/v5/response.go @@ -367,6 +367,25 @@ type CostResponse struct { Cost *Cost `json:"cost,omitempty,brackets"` } +// FilesResponse type +type FilesResponse struct { + Success bool `json:"success"` + Pagination *Pagination `json:"pagination,omitempty"` + Files []File `json:"files,omitempty,brackets"` +} + +// FileUpload response +type FileUploadResponse struct { + Success bool `json:"success"` + File *File `json:"file,omitempty"` +} + +// FileResponse type +type FileResponse struct { + Success bool `json:"success"` + File *File `json:"file,omitempty"` +} + // CustomFieldsResponse type type CustomFieldsResponse struct { Success bool `json:"success"` @@ -401,6 +420,6 @@ type CustomFieldResponse struct { // UnitsResponse type type UnitsResponse struct { - Success bool `json:"success"` - Units *[]Unit `json:"units,omitempty,brackets"` + Success bool `json:"success"` + Units *[]Unit `json:"units,omitempty,brackets"` } diff --git a/v5/types.go b/v5/types.go index 0c3f019..52d8269 100644 --- a/v5/types.go +++ b/v5/types.go @@ -923,6 +923,22 @@ type Cost struct { Sites []string `json:"sites,omitempty,brackets"` } +// File type +type File struct { + ID int `json:"id,omitempty"` + Filename string `json:"filename,omitempty"` + Type string `json:"type,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + Size int `json:"size,omitempty"` + Attachment []Attachment `json:"attachment,omitempty"` +} + +// Attachment type +type Attachment struct { + Customer *Customer `json:"customer,omitempty"` + Order *Order `json:"order,omitempty"` +} + // CustomFields type type CustomFields struct { Name string `json:"name,omitempty"` From 446e9a0e54ad4d1e47ce06aa88201fbfbacdd950 Mon Sep 17 00:00:00 2001 From: Pavel Date: Mon, 9 Sep 2019 14:57:58 +0300 Subject: [PATCH 2/4] FileEdit fix, remove reduntant struct tag, improved test data --- v5/client.go | 4 +++- v5/client_test.go | 13 ++----------- v5/response.go | 2 +- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/v5/client.go b/v5/client.go index 06fd460..6544c67 100644 --- a/v5/client.go +++ b/v5/client.go @@ -4173,7 +4173,9 @@ func (c *Client) FileEdit(id int, file File) (FileResponse, int, *errs.Failure) var resp FileResponse req, _ := json.Marshal(file) - data, status, err := c.PostRequest(fmt.Sprintf("/files/%d/edit", id), bytes.NewReader(req)) + data, status, err := c.PostRequest(fmt.Sprintf("/files/%d/edit", id), url.Values{ + "file": {string(req)}, + }) if err != nil && err.Error() != "" { return resp, status, err diff --git a/v5/client_test.go b/v5/client_test.go index e6ed9a7..8f7d2c2 100644 --- a/v5/client_test.go +++ b/v5/client_test.go @@ -4764,16 +4764,7 @@ func TestClient_Files(t *testing.T) { MatchParam("limit", "20"). MatchParam("page", "1"). Reply(200). - BodyString(`{ - "success": true, - "pagination": { - "limit": 20, - "totalCount": 0, - "currentPage": 1, - "totalPageCount": 0 - }, - "files": [] - }`) + BodyString(`{"success": true,"pagination": {"limit": 20,"totalCount": 0,"currentPage": 1,"totalPageCount": 0},"files": []}`) _, status, err := c.Files(FilesRequest{ Limit: 20, @@ -4826,7 +4817,7 @@ func TestClient_FileUploadFail(t *testing.T) { gock.New(crmURL). Post("/files/upload"). Reply(400). - BodyString(`{"success": false, "errorMsg": "error"}`) + BodyString(`{"success":false,"errorMsg":"Your account doesn't have enough money to upload files."}`) _, status, err := c.FileUpload(file) if err.Error() != "" { diff --git a/v5/response.go b/v5/response.go index 72467c8..557c6ca 100644 --- a/v5/response.go +++ b/v5/response.go @@ -371,7 +371,7 @@ type CostResponse struct { type FilesResponse struct { Success bool `json:"success"` Pagination *Pagination `json:"pagination,omitempty"` - Files []File `json:"files,omitempty,brackets"` + Files []File `json:"files,omitempty"` } // FileUpload response From ac2e0c0c7b9e2919712539fb262cfde1fac30027 Mon Sep 17 00:00:00 2001 From: Pavel Date: Mon, 9 Sep 2019 15:01:17 +0300 Subject: [PATCH 3/4] use builtin errors --- v5/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/v5/client.go b/v5/client.go index 6544c67..50970b6 100644 --- a/v5/client.go +++ b/v5/client.go @@ -3,6 +3,7 @@ package v5 import ( "bytes" "encoding/json" + "errors" "fmt" "io" "io/ioutil" @@ -14,7 +15,6 @@ import ( "time" "github.com/google/go-querystring/query" - "github.com/pkg/errors" "github.com/retailcrm/api-client-go/errs" ) From a0297dfae8a5f9d1058ba938db27179406c354bf Mon Sep 17 00:00:00 2001 From: Pavel Date: Mon, 9 Sep 2019 15:39:26 +0300 Subject: [PATCH 4/4] remove api key from parameter --- v5/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/v5/client.go b/v5/client.go index 50970b6..2953ccc 100644 --- a/v5/client.go +++ b/v5/client.go @@ -4034,7 +4034,7 @@ func (c *Client) Files(files FilesRequest) (FilesResponse, int, *errs.Failure) { func (c *Client) FileUpload(reader io.Reader) (FileUploadResponse, int, *errs.Failure) { var resp FileUploadResponse - data, status, err := c.PostRequest(fmt.Sprintf("/files/upload?apiKey=%s", c.Key), reader, "application/octet-stream") + data, status, err := c.PostRequest("/files/upload", reader, "application/octet-stream") if err != nil && err.Error() != "" { return resp, status, err