From 7b88c49fcb28a52da25c3418acf7af76469cc9a4 Mon Sep 17 00:00:00 2001 From: linstohu Date: Tue, 14 Nov 2023 01:08:59 +0800 Subject: [PATCH] feat: add htx least api codes --- htx/README.md | 15 +++ htx/rest/spot/client.go | 121 +++++++++++++++++++ htx/rest/spot/client_test.go | 51 ++++++++ htx/rest/spot/types/account.go | 29 +++++ htx/rest/utils/client.go | 210 +++++++++++++++++++++++++++++++++ htx/rest/utils/request.go | 64 ++++++++++ htx/rest/utils/response.go | 101 ++++++++++++++++ htx/rest/utils/vars.go | 31 +++++ kucoin/rest/utils/client.go | 4 +- kucoin/rest/utils/response.go | 10 +- 10 files changed, 630 insertions(+), 6 deletions(-) create mode 100644 htx/README.md create mode 100644 htx/rest/spot/client.go create mode 100644 htx/rest/spot/client_test.go create mode 100644 htx/rest/spot/types/account.go create mode 100644 htx/rest/utils/client.go create mode 100644 htx/rest/utils/request.go create mode 100644 htx/rest/utils/response.go create mode 100644 htx/rest/utils/vars.go diff --git a/htx/README.md b/htx/README.md new file mode 100644 index 0000000..8fba4f2 --- /dev/null +++ b/htx/README.md @@ -0,0 +1,15 @@ +# HTX + +## About HTX + +Huobi Global is an international digital asset exchange with a presence in over 100 countries. Founded in China in 2013, the platform works with global clients and provides trading and investment services to users around the world, handling over $4 billion in daily trading volume. + +The ecosystem includes spot trading, margin trading, futures trading, derivatives trading, staking, crypto loans and more. Active traders and investors have the opportunity to enter OTC trading platforms and gain access to custom trading instruments. + +## Official Site + +- homepage: https://www.htx.com/ + +## API Documents + +https://www.htx.com/en-us/opend/newApiPages/ diff --git a/htx/rest/spot/client.go b/htx/rest/spot/client.go new file mode 100644 index 0000000..804ed64 --- /dev/null +++ b/htx/rest/spot/client.go @@ -0,0 +1,121 @@ +/* + * 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 spot + +import ( + "context" + "errors" + "log/slog" + "net/http" + + "github.com/go-playground/validator" + "github.com/linstohu/nexapi/htx/rest/spot/types" + "github.com/linstohu/nexapi/htx/rest/utils" +) + +type SpotClient struct { + cli *utils.HTXClient + + // validate struct fields + validate *validator.Validate +} + +type SpotClientCfg struct { + Debug bool + // Logger + Logger *slog.Logger + + BaseURL string `validate:"required"` + Key string `validate:"required"` + Secret string `validate:"required"` + SignVersion string `validate:"required"` +} + +func NewSpotClient(cfg *SpotClientCfg) (*SpotClient, error) { + validator := validator.New() + + err := validator.Struct(cfg) + if err != nil { + return nil, err + } + + cli, err := utils.NewHTXRestClient(&utils.HTXClientCfg{ + Debug: cfg.Debug, + Logger: cfg.Logger, + BaseURL: cfg.BaseURL, + Key: cfg.Key, + Secret: cfg.Secret, + SignVersion: cfg.SignVersion, + }) + if err != nil { + return nil, err + } + + return &SpotClient{ + cli: cli, + validate: validator, + }, nil +} + +func (scli *SpotClient) GetAccountInfo(ctx context.Context) (*types.GetAccountInfoResponse, error) { + req := utils.HTTPRequest{ + BaseURL: scli.cli.GetBaseURL(), + Path: "/v1/account/accounts", + Method: http.MethodGet, + } + + { + headers, err := scli.cli.GetHeaders() + if err != nil { + return nil, err + } + req.Headers = headers + } + + { + values, err := scli.cli.GenSignatureValues(req) + if err != nil { + return nil, err + } + + signStr, err := scli.cli.NormalizeRequestContent(req, values) + if err != nil { + return nil, err + } + + h := scli.cli.Sign([]byte(signStr)) + if err != nil { + return nil, err + } + + values.Add("Signature", h) + req.Query = values + } + + resp, err := scli.cli.SendHTTPRequest(ctx, req) + if err != nil { + return nil, err + } + + var ret types.GetAccountInfoResponse + if err := resp.ReadJsonBody(&ret); err != nil { + return nil, errors.New(resp.Error()) + } + + return &ret, nil +} diff --git a/htx/rest/spot/client_test.go b/htx/rest/spot/client_test.go new file mode 100644 index 0000000..0aae1d4 --- /dev/null +++ b/htx/rest/spot/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 spot + +import ( + "context" + "os" + "testing" + + "github.com/linstohu/nexapi/htx/rest/utils" + "github.com/stretchr/testify/assert" +) + +func testNewSpotClient(t *testing.T) *SpotClient { + cli, err := NewSpotClient(&SpotClientCfg{ + BaseURL: utils.ProdAWSBaseURL, + Key: os.Getenv("HTX_KEY"), + Secret: os.Getenv("HTX_SECRET"), + SignVersion: utils.ApiKeyVersionV2, + Debug: true, + }) + + if err != nil { + t.Fatalf("Could not create htx client, %s", err) + } + + return cli +} + +func TestGetAccountInfo(t *testing.T) { + cli := testNewSpotClient(t) + + _, err := cli.GetAccountInfo(context.TODO()) + + assert.Nil(t, err) +} diff --git a/htx/rest/spot/types/account.go b/htx/rest/spot/types/account.go new file mode 100644 index 0000000..5e6d2fd --- /dev/null +++ b/htx/rest/spot/types/account.go @@ -0,0 +1,29 @@ +/* + * 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 GetAccountInfoResponse struct { + Status string `json:"status"` + Data []AccountInfo `json:"data"` +} +type AccountInfo struct { + Id int64 `json:"id"` + Type string `json:"type"` + Subtype string `json:"subtype"` + State string `json:"state"` +} diff --git a/htx/rest/utils/client.go b/htx/rest/utils/client.go new file mode 100644 index 0000000..1f6783b --- /dev/null +++ b/htx/rest/utils/client.go @@ -0,0 +1,210 @@ +/* + * 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" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httputil" + "net/url" + "strings" + "time" + + "github.com/go-playground/validator" + goquery "github.com/google/go-querystring/query" +) + +type HTXClient struct { + // debug mode + debug bool + // logger + logger *slog.Logger + + baseURL string + key, secret string + signVersion string +} + +type HTXClientCfg struct { + Debug bool + // Logger + Logger *slog.Logger + + BaseURL string `validate:"required"` + Key string + Secret string + SignVersion string +} + +func NewHTXRestClient(cfg *HTXClientCfg) (*HTXClient, error) { + err := validator.New().Struct(cfg) + if err != nil { + return nil, err + } + + cli := HTXClient{ + debug: cfg.Debug, + logger: cfg.Logger, + baseURL: cfg.BaseURL, + key: cfg.Key, + secret: cfg.Secret, + signVersion: cfg.SignVersion, + } + + if cli.logger == nil { + cli.logger = slog.Default() + } + + return &cli, nil +} + +func (htx *HTXClient) GetDebug() bool { + return htx.debug +} + +func (htx *HTXClient) GetBaseURL() string { + return htx.baseURL +} + +func (htx *HTXClient) GetKey() string { + return htx.key +} + +func (htx *HTXClient) GetSecret() string { + return htx.secret +} + +func (htx *HTXClient) GetHeaders() (map[string]string, error) { + return map[string]string{ + "Content-Type": "application/json", + "Accept": "application/json", + }, nil +} + +func (htx *HTXClient) GenSignatureValues(req HTTPRequest) (url.Values, error) { + parameters := url.Values{} + + if req.QueryParams != nil { + q, err := goquery.Values(req.QueryParams) + if err != nil { + return nil, err + } + parameters = q + } + + parameters.Add("AccessKeyId", htx.key) + parameters.Add("SignatureMethod", "HmacSHA256") + parameters.Add("SignatureVersion", htx.signVersion) + parameters.Add("Timestamp", time.Now().UTC().Format("2006-01-02T15:04:05")) + + return parameters, nil +} + +func (htx *HTXClient) NormalizeRequestContent(req HTTPRequest, parameters url.Values) (string, error) { + if req.Method == "" || req.BaseURL == "" || req.Path == "" || parameters.Encode() == "" { + return "", fmt.Errorf("gen signature error: method(%s), baseurl(%s), path(%s) and parameters(%s) should not be empty", + req.Method, req.BaseURL, req.Path, parameters.Encode()) + } + + url, err := url.Parse(req.BaseURL + req.Path) + if err != nil { + return "", err + } + + var sb strings.Builder + sb.WriteString(req.Method) + sb.WriteString("\n") + sb.WriteString(url.Host) + sb.WriteString("\n") + sb.WriteString(req.Path) + sb.WriteString("\n") + sb.WriteString(parameters.Encode()) + + return sb.String(), nil +} + +// sign makes a signature by sha256. +func (htx *HTXClient) Sign(plain []byte) string { + hm := hmac.New(sha256.New, []byte(htx.secret)) + hm.Write(plain) + return base64.StdEncoding.EncodeToString(hm.Sum(nil)) +} + +func (htx *HTXClient) SendHTTPRequest(ctx context.Context, req HTTPRequest) (*HTTPResponse, error) { + client := http.Client{} + + var body io.Reader + if req.Body != nil { + jsonBody, err := json.Marshal(req.Body) + if err != nil { + return nil, err + } + body = bytes.NewReader(jsonBody) + } + + url, err := url.Parse(req.BaseURL + req.Path) + if err != nil { + return nil, err + } + + if req.Query != nil { + url.RawQuery = req.Query.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 htx.GetDebug() { + dump, err := httputil.DumpRequestOut(request, true) + if err != nil { + return nil, err + } + + htx.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 htx.GetDebug() { + dump, err := httputil.DumpResponse(resp, true) + if err != nil { + return nil, err + } + htx.logger.Info(fmt.Sprintf("\n%s\n", string(dump))) + } + + return NewResponse(&req, resp, nil), nil +} diff --git a/htx/rest/utils/request.go b/htx/rest/utils/request.go new file mode 100644 index 0000000..3394ddd --- /dev/null +++ b/htx/rest/utils/request.go @@ -0,0 +1,64 @@ +/* + * 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" +) + +type HTTPRequest struct { + BaseURL string + Path string + Method string + Headers map[string]string + Query url.Values + QueryParams 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 { + url.RawQuery = h.Query.Encode() + } + + return url.RequestURI(), 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/htx/rest/utils/response.go b/htx/rest/utils/response.go new file mode 100644 index 0000000..4d2faec --- /dev/null +++ b/htx/rest/utils/response.go @@ -0,0 +1,101 @@ +/* + * 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" + "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()) + } + + var body []byte + if r.Body != nil { + body = r.Body + } else { + body = []byte(NIL) + } + + 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(body), + ) + + return m +} diff --git a/htx/rest/utils/vars.go b/htx/rest/utils/vars.go new file mode 100644 index 0000000..4b90054 --- /dev/null +++ b/htx/rest/utils/vars.go @@ -0,0 +1,31 @@ +/* + * 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 ( + ProdBaseURL = "https://api.huobi.pro" + ProdAWSBaseURL = "https://api-aws.huobi.pro" +) + +var NIL = "" + +// ApiKeyVersionV1 is v1 api key version +const ApiKeyVersionV1 = "1" + +// ApiKeyVersionV2 is v2 api key version +const ApiKeyVersionV2 = "2" diff --git a/kucoin/rest/utils/client.go b/kucoin/rest/utils/client.go index cafdacf..aa99e79 100644 --- a/kucoin/rest/utils/client.go +++ b/kucoin/rest/utils/client.go @@ -125,7 +125,7 @@ func (k *KucoinClient) GenSignature(req HTTPRequest) (map[string]string, error) b.WriteString(uri) if reqBody != NIL { - b.Write([]byte(reqBody)) + b.WriteString(reqBody) } t := time.Now().UnixMilli() @@ -138,7 +138,7 @@ func (k *KucoinClient) GenSignature(req HTTPRequest) (map[string]string, error) "KC-API-PASSPHRASE": k.passphrase, "KC-API-TIMESTAMP": fmt.Sprintf("%v", t), "KC-API-SIGN": s, - "KC-API-KEY-VERSION": "2", + "KC-API-KEY-VERSION": k.keyVersion, } return ksHeaders, nil diff --git a/kucoin/rest/utils/response.go b/kucoin/rest/utils/response.go index c195613..e439864 100644 --- a/kucoin/rest/utils/response.go +++ b/kucoin/rest/utils/response.go @@ -82,9 +82,11 @@ func (r *HTTPResponse) Error() string { 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) + var body []byte + if r.Body != nil { + body = r.Body + } else { + body = []byte(NIL) } m := fmt.Sprintf("[Parse]Failure: parse JSON body failed because %s, %s %s with body=%s, respond code=%d body=%s", @@ -93,7 +95,7 @@ func (r *HTTPResponse) Error() string { uri, reqBody, r.Resp.StatusCode, - string(rb), + string(body), ) return m