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"`