From fcd8b655bf6e8ada6bc2384d91f50ca9d57d948e Mon Sep 17 00:00:00 2001 From: linstohu Date: Mon, 13 Nov 2023 17:30:26 +0800 Subject: [PATCH] feat: add kucoin least api codes --- kucoin/rest/account/client.go | 122 +++++++++++++++ kucoin/rest/account/client_test.go | 51 +++++++ kucoin/rest/account/types/accounts.go | 33 +++++ kucoin/rest/utils/client.go | 206 ++++++++++++++++++++++++++ kucoin/rest/utils/pagination.go | 62 ++++++++ kucoin/rest/utils/request.go | 69 +++++++++ kucoin/rest/utils/response.go | 191 ++++++++++++++++++++++++ kucoin/rest/utils/vars.go | 25 ++++ 8 files changed, 759 insertions(+) create mode 100644 kucoin/rest/account/client.go create mode 100644 kucoin/rest/account/client_test.go create mode 100644 kucoin/rest/account/types/accounts.go create mode 100644 kucoin/rest/utils/client.go create mode 100644 kucoin/rest/utils/pagination.go create mode 100644 kucoin/rest/utils/request.go create mode 100644 kucoin/rest/utils/response.go create mode 100644 kucoin/rest/utils/vars.go diff --git a/kucoin/rest/account/client.go b/kucoin/rest/account/client.go new file mode 100644 index 0000000..f17e151 --- /dev/null +++ b/kucoin/rest/account/client.go @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2023, LinstoHu + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package account + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + + "github.com/go-playground/validator" + "github.com/linstohu/nexapi/kucoin/rest/account/types" + "github.com/linstohu/nexapi/kucoin/rest/utils" +) + +type AccountClient struct { + cli *utils.KucoinClient + + // validate struct fields + validate *validator.Validate +} + +type AccountClientCfg struct { + Debug bool + // Logger + Logger *slog.Logger + + BaseURL string `validate:"required"` + Key string `validate:"required"` + Secret string `validate:"required"` + Passphrase string `validate:"required"` +} + +func NewAccountClient(cfg *AccountClientCfg) (*AccountClient, error) { + validator := validator.New() + + err := validator.Struct(cfg) + if err != nil { + return nil, err + } + + cli, err := utils.NewKucoinRestClient(&utils.KucoinClientCfg{ + Debug: cfg.Debug, + Logger: cfg.Logger, + BaseURL: cfg.BaseURL, + Key: cfg.Key, + Secret: cfg.Secret, + }) + if err != nil { + return nil, err + } + + return &AccountClient{ + cli: cli, + validate: validator, + }, nil +} + +func (a *AccountClient) GetAccountList(ctx context.Context, param types.GetAccountListParam) ([]*types.AccountModel, error) { + req := utils.HTTPRequest{ + BaseURL: a.cli.GetBaseURL(), + Path: "/api/v1/accounts", + Method: http.MethodGet, + Query: param, + } + + { + headers, err := a.cli.GetHeaders() + if err != nil { + return nil, err + } + req.Headers = headers + } + + { + err := a.validate.Struct(param) + if err != nil { + return nil, err + } + + h, err := a.cli.GenSignature(req) + if err != nil { + return nil, err + } + for k, v := range h { + req.Headers[k] = v + } + } + + resp, err := a.cli.SendHTTPRequest(ctx, req) + if err != nil { + return nil, err + } + + ar := &utils.ApiResponse{Resp: resp} + if err := resp.ReadJsonBody(ar); err != nil { + return nil, errors.New(resp.Error()) + } + + var ret []*types.AccountModel + if err := json.Unmarshal(ar.RawData, &ret); err != nil { + return nil, err + } + + return ret, nil +} diff --git a/kucoin/rest/account/client_test.go b/kucoin/rest/account/client_test.go new file mode 100644 index 0000000..99f3c81 --- /dev/null +++ b/kucoin/rest/account/client_test.go @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2023, LinstoHu + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package account + +import ( + "context" + "os" + "testing" + + "github.com/linstohu/nexapi/kucoin/rest/account/types" + "github.com/linstohu/nexapi/kucoin/rest/utils" + "github.com/stretchr/testify/assert" +) + +func testNewAccountClient(t *testing.T) *AccountClient { + cli, err := NewAccountClient(&AccountClientCfg{ + BaseURL: utils.SpotBaseURL, + Key: os.Getenv("KUCOIN_KEY"), + Secret: os.Getenv("KUCOIN_SECRET"), + Debug: true, + }) + + if err != nil { + t.Fatalf("Could not create kucoin client, %s", err) + } + + return cli +} + +func TestGetAccountList(t *testing.T) { + cli := testNewAccountClient(t) + + _, err := cli.GetAccountList(context.TODO(), types.GetAccountListParam{}) + + assert.Nil(t, err) +} diff --git a/kucoin/rest/account/types/accounts.go b/kucoin/rest/account/types/accounts.go new file mode 100644 index 0000000..2e9c198 --- /dev/null +++ b/kucoin/rest/account/types/accounts.go @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023, LinstoHu + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package types + +type GetAccountListParam struct { + Currency string `url:"currency" validate:"omitempty"` + Type string `url:"type" validate:"omitempty"` +} + +// An AccountModel represents an account. +type AccountModel struct { + Id string `json:"id"` + Currency string `json:"currency"` + Type string `json:"type"` + Balance string `json:"balance"` + Available string `json:"available"` + Holds string `json:"holds"` +} diff --git a/kucoin/rest/utils/client.go b/kucoin/rest/utils/client.go new file mode 100644 index 0000000..0491f1b --- /dev/null +++ b/kucoin/rest/utils/client.go @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2023, LinstoHu + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package utils + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httputil" + "net/url" + "strings" + "time" + + "github.com/go-playground/validator" + "github.com/google/go-querystring/query" +) + +type KucoinClient struct { + // debug mode + debug bool + // logger + logger *slog.Logger + + baseURL string + key, secret, passphrase string +} + +type KucoinClientCfg struct { + Debug bool + // Logger + Logger *slog.Logger + + BaseURL string `validate:"required"` + Key string + Secret string + Passphrase string +} + +func NewKucoinRestClient(cfg *KucoinClientCfg) (*KucoinClient, error) { + err := validator.New().Struct(cfg) + if err != nil { + return nil, err + } + + cli := KucoinClient{ + debug: cfg.Debug, + logger: cfg.Logger, + baseURL: cfg.BaseURL, + key: cfg.Key, + secret: cfg.Secret, + passphrase: cfg.Passphrase, + } + + if cli.logger == nil { + cli.logger = slog.Default() + } + + return &cli, nil +} + +func (k *KucoinClient) GetDebug() bool { + return k.debug +} + +func (k *KucoinClient) GetBaseURL() string { + return k.baseURL +} + +func (k *KucoinClient) GetKey() string { + return k.key +} + +func (k *KucoinClient) GetSecret() string { + return k.secret +} + +func (k *KucoinClient) GetPassphrase() string { + return k.passphrase +} + +func (k *KucoinClient) GetHeaders() (map[string]string, error) { + return map[string]string{ + "Content-Type": "application/json", + "Accept": "application/json", + }, nil +} + +func (k *KucoinClient) GenSignature(req HTTPRequest) (map[string]string, error) { + uri, err := req.RequestURI() + if err != nil { + return nil, err + } + + reqBody, err := req.RequestBody() + if err != nil { + return nil, err + } + + var b bytes.Buffer + + b.WriteString(req.Method) + b.WriteString(uri) + + if reqBody != NIL { + b.Write([]byte(reqBody)) + } + + t := IntToString(time.Now().UnixNano() / 1000000) + p := []byte(t + b.String()) + s := string(k.Sign(p)) + ksHeaders := map[string]string{ + "KC-API-KEY": k.key, + "KC-API-PASSPHRASE": k.passphrase, + "KC-API-TIMESTAMP": t, + "KC-API-SIGN": s, + } + + return ksHeaders, nil +} + +// Sign makes a signature by sha256. +func (k *KucoinClient) Sign(plain []byte) []byte { + hm := hmac.New(sha256.New, []byte(k.secret)) + hm.Write(plain) + return hm.Sum(nil) +} + +func (s *KucoinClient) SendHTTPRequest(ctx context.Context, req HTTPRequest) (*HTTPResponse, error) { + client := http.Client{} + + var body io.Reader + if req.Body != nil { + formData, err := query.Values(req.Body) + if err != nil { + return nil, err + } + body = strings.NewReader(formData.Encode()) + } + + url, err := url.Parse(req.BaseURL + req.Path) + if err != nil { + return nil, err + } + + if req.Query != nil { + q, err := query.Values(req.Query) + if err != nil { + return nil, err + } + url.RawQuery = q.Encode() + } + + request, err := http.NewRequestWithContext(ctx, req.Method, url.String(), body) + if err != nil { + return nil, err + } + + for k, v := range req.Headers { + request.Header.Set(k, v) + } + + if s.GetDebug() { + dump, err := httputil.DumpRequestOut(request, true) + if err != nil { + return nil, err + } + + s.logger.Info(fmt.Sprintf("\n%s\n", string(dump))) + } + + resp, err := client.Do(request) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if s.GetDebug() { + dump, err := httputil.DumpResponse(resp, true) + if err != nil { + return nil, err + } + s.logger.Info(fmt.Sprintf("\n%s\n", string(dump))) + } + + return NewResponse(&req, resp, nil), nil +} diff --git a/kucoin/rest/utils/pagination.go b/kucoin/rest/utils/pagination.go new file mode 100644 index 0000000..b7adf8b --- /dev/null +++ b/kucoin/rest/utils/pagination.go @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2023, LinstoHu + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package utils + +import ( + "encoding/json" + "strconv" +) + +// A PaginationParam represents the pagination parameters `currentPage` `pageSize` in a request . +type PaginationParam struct { + CurrentPage int64 + PageSize int64 +} + +// ReadParam read pagination parameters into params. +func (p *PaginationParam) ReadParam(params map[string]string) { + params["currentPage"], params["pageSize"] = IntToString(p.CurrentPage), IntToString(p.PageSize) +} + +// A PaginationModel represents the pagination in a response. +type PaginationModel struct { + CurrentPage int64 `json:"currentPage"` + PageSize int64 `json:"pageSize"` + TotalNum int64 `json:"totalNum"` + TotalPage int64 `json:"totalPage"` + RawItems json.RawMessage `json:"items"` // delay parsing +} + +// ReadItems read the `items` into v. +func (p *PaginationModel) ReadItems(v interface{}) error { + return json.Unmarshal(p.RawItems, v) +} + +// IntToString converts int64 to string. +func IntToString(i int64) string { + return strconv.FormatInt(i, 10) +} + +// ToJsonString converts any value to JSON string. +func ToJsonString(v interface{}) string { + b, err := json.Marshal(v) + if err != nil { + return "" + } + return string(b) +} diff --git a/kucoin/rest/utils/request.go b/kucoin/rest/utils/request.go new file mode 100644 index 0000000..7458a42 --- /dev/null +++ b/kucoin/rest/utils/request.go @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2023, LinstoHu + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package utils + +import ( + "encoding/json" + "net/url" + + "github.com/google/go-querystring/query" +) + +type HTTPRequest struct { + BaseURL string + Path string + Method string + Headers map[string]string + Query any + Body any +} + +// RequestURI returns the request uri. +func (h *HTTPRequest) RequestURI() (string, error) { + url, err := url.Parse(h.BaseURL + h.Path) + if err != nil { + return "", err + } + + if h.Query != nil { + q, err := query.Values(h.Query) + if err != nil { + return "", err + } + url.RawQuery = q.Encode() + } + + return url.String(), nil +} + +func (h *HTTPRequest) RequestBody() (string, error) { + if h.Body == nil { + return NIL, nil + } + + var body string + if h.Body != nil { + jsonBody, err := json.Marshal(h.Body) + if err != nil { + return "", err + } + body = string(jsonBody) + } + + return body, nil +} diff --git a/kucoin/rest/utils/response.go b/kucoin/rest/utils/response.go new file mode 100644 index 0000000..c195613 --- /dev/null +++ b/kucoin/rest/utils/response.go @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2023, LinstoHu + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package utils + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" +) + +// A HTTPResponse represents a HTTP response. +type HTTPResponse struct { + Req *HTTPRequest + Resp *http.Response + Body []byte +} + +// NewResponse Creates a new Response +func NewResponse( + request *HTTPRequest, + response *http.Response, + body []byte, +) *HTTPResponse { + return &HTTPResponse{ + Req: request, + Resp: response, + Body: body, + } +} + +// ReadBody read the response data, then return it. +func (r *HTTPResponse) ReadBody() ([]byte, error) { + if r.Body != nil { + return r.Body, nil + } + + r.Body = make([]byte, 0) + defer r.Resp.Body.Close() + + buf := new(bytes.Buffer) + buf.ReadFrom(r.Resp.Body) + + r.Body = buf.Bytes() + + return r.Body, nil +} + +// ReadJsonBody read the response data as JSON into v. +func (r *HTTPResponse) ReadJsonBody(v interface{}) error { + b, err := r.ReadBody() + if err != nil { + return err + } + return json.Unmarshal(b, v) +} + +func (r *HTTPResponse) Error() string { + uri, err := r.Req.RequestURI() + if err != nil { + return fmt.Sprintf("get request uri error: %s", err.Error()) + } + + reqBody, err := r.Req.RequestBody() + if err != nil { + return fmt.Sprintf("get request body error: %s", err.Error()) + } + + rb, err := r.ReadBody() + if err != nil { + return fmt.Sprintf("read body error, %s %s", r.Req.Method, uri) + } + + m := fmt.Sprintf("[Parse]Failure: parse JSON body failed because %s, %s %s with body=%s, respond code=%d body=%s", + err.Error(), + r.Req.Method, + uri, + reqBody, + r.Resp.StatusCode, + string(rb), + ) + + return m +} + +// The predefined API codes +const ( + ApiSuccess = "200000" +) + +// An ApiResponse represents a API response wrapped Response. +type ApiResponse struct { + Resp *HTTPResponse + Code string `json:"code"` + RawData json.RawMessage `json:"data"` // delay parsing + Message string `json:"msg"` +} + +// HttpSuccessful judges the success of http. +func (ar *ApiResponse) HttpSuccessful() bool { + return ar.Resp.Resp.StatusCode == http.StatusOK +} + +// ApiSuccessful judges the success of API. +func (ar *ApiResponse) ApiSuccessful() bool { + return ar.Code == ApiSuccess +} + +// ReadData read the api response `data` as JSON into v. +func (ar *ApiResponse) ReadData(v interface{}) error { + reqURI, err := ar.Resp.Req.RequestURI() + if err != nil { + return err + } + + reqBody, err := ar.Resp.Req.RequestBody() + if err != nil { + return err + } + + if !ar.HttpSuccessful() { + rsb, _ := ar.Resp.ReadBody() + m := fmt.Sprintf("[HTTP]Failure: status code is NOT 200, %s %s with body=%s, respond code=%d body=%s", + ar.Resp.Req.Method, + reqURI, + reqBody, + ar.Resp.Resp.StatusCode, + string(rsb), + ) + return errors.New(m) + } + + if !ar.ApiSuccessful() { + m := fmt.Sprintf("[API]Failure: api code is NOT %s, %s %s with body=%s, respond code=%s message=\"%s\" data=%s", + ApiSuccess, + ar.Resp.Req.Method, + reqURI, + reqBody, + ar.Code, + ar.Message, + string(ar.RawData), + ) + return errors.New(m) + } + // when input parameter v is nil, read nothing and return nil + if v == nil { + return nil + } + + if len(ar.RawData) == 0 { + m := fmt.Sprintf("[API]Failure: try to read empty data, %s %s with body=%s, respond code=%s message=\"%s\" data=%s", + ar.Resp.Req.Method, + reqURI, + reqBody, + ar.Code, + ar.Message, + string(ar.RawData), + ) + return errors.New(m) + } + + return json.Unmarshal(ar.RawData, v) +} + +// ReadPaginationData read the data `items` as JSON into v, and returns *PaginationModel. +func (ar *ApiResponse) ReadPaginationData(v interface{}) (*PaginationModel, error) { + p := &PaginationModel{} + if err := ar.ReadData(p); err != nil { + return nil, err + } + if err := p.ReadItems(v); err != nil { + return p, err + } + return p, nil +} diff --git a/kucoin/rest/utils/vars.go b/kucoin/rest/utils/vars.go new file mode 100644 index 0000000..0771582 --- /dev/null +++ b/kucoin/rest/utils/vars.go @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023, LinstoHu + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package utils + +var ( + SpotBaseURL = "https://api.kucoin.com" + FuturesBaseURL = "https://api-futures.kucoin.com" +) + +var NIL = "" \ No newline at end of file