diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..e39a5ed --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: nao1215 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..bfdc015 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,23 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[BUG Report] " +labels: bug +assignees: '' + +--- + +## coincheck version** +v0.y.z + +## Description (About the problem) +A clear description of the bug encountered. + +## Steps to reproduce +Steps to reproduce the bug. + +## Expected behavior +Expected behavior. + +## Additional details** +Any other useful data to share. diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md new file mode 100644 index 0000000..64a391b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +## Is your feature request related to a problem? +A clear and concise description of what the problem is. E.g. I'm always frustrated when ... + +## Describe the solution you'd like +A clear and concise description of what you want to happen. + +## Describe alternatives you've considered +A clear and concise description of any alternative solutions or features you've considered. + +## Additional context +Add any other context or screenshots about the feature request here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5463252 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: "/" + schedule: + interval: daily + time: "20:00" + open-pull-requests-limit: 10 + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..34d0371 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,31 @@ +name: Coverage + +on: + workflow_dispatch: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + unit_test: + name: Unit test (linux) + + strategy: + matrix: + platform: [ubuntu-latest] + + runs-on: ${{ matrix.platform }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1" + check-latest: true + + - name: Run tests with coverage report output + run: go test -cover -coverpkg=./... -coverprofile=coverage.out ./... + + - uses: k1LoW/octocov-action@v1 diff --git a/.github/workflows/gitleak.yml b/.github/workflows/gitleak.yml new file mode 100644 index 0000000..6f99525 --- /dev/null +++ b/.github/workflows/gitleak.yml @@ -0,0 +1,13 @@ +name: gitleaks +on: [pull_request] +jobs: + scan: + name: gitleaks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/reviewdog.yml b/.github/workflows/reviewdog.yml new file mode 100644 index 0000000..ebaac1e --- /dev/null +++ b/.github/workflows/reviewdog.yml @@ -0,0 +1,41 @@ +name: reviewdog +on: [pull_request] + +jobs: + golangci-lint: + name: golangci-lint + runs-on: ubuntu-latest + steps: + - name: Check out code into the Go module directory + uses: actions/checkout@v4 + with: + persist-credentials: false + - name: golangci-lint + uses: reviewdog/action-golangci-lint@v2 + with: + reporter: github-pr-review + level: warning + + misspell: + name: misspell + runs-on: ubuntu-latest + steps: + - name: Check out code into the Go module directory + uses: actions/checkout@v4 + with: + persist-credentials: false + - name: misspell + uses: reviewdog/action-misspell@v1 + with: + reporter: github-pr-review + level: warning + locale: "US" + + actionlint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: reviewdog/action-actionlint@v1 + with: + reporter: github-pr-review + level: warning diff --git a/.github/workflows/unit_test.yml b/.github/workflows/unit_test.yml new file mode 100644 index 0000000..f335e11 --- /dev/null +++ b/.github/workflows/unit_test.yml @@ -0,0 +1,37 @@ +name: MultiPlatformUnitTest + +on: + workflow_dispatch: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + unit_test: + name: Unit tests + + strategy: + matrix: + os: + - "ubuntu-latest" + - "windows-latest" + - "macos-latest" + go: + - "1" + - "1.22" + - "1.21" + - "1.20" + fail-fast: false + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go }} + + - name: Run tests with coverage report output + run: go test -cover -coverpkg=./... -coverprofile=coverage.out ./... + diff --git a/.gitignore b/.gitignore index 6f6f5e6..fdd2277 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ # Go workspace file go.work go.work.sum + +# Project coverage profile +/coverage.* diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..519005e --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,50 @@ +run: + go: "1.20" + +issues: + exclude-use-default: false + +linters: + disable-all: true + enable: + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - typecheck + - unused + - asciicheck + - bodyclose + - dogsled + - durationcheck + - errorlint + - exhaustive + - exportloopref + - forcetypeassert + - gochecknoglobals + - gochecknoinits + - goconst + - gocritic + - goimports + - gosec + - misspell + - nakedret + - noctx + - nolintlint + - prealloc + - rowserrcheck + - sqlclosecheck + - stylecheck + - tagliatelle + - thelper + - unconvert + - unparam + - wastedassign + - whitespace +linters-settings: + tagliatelle: + case: + use-field-name: true + rules: + json: snake diff --git a/.octocov.yml b/.octocov.yml new file mode 100644 index 0000000..9201d9f --- /dev/null +++ b/.octocov.yml @@ -0,0 +1,21 @@ +# generated by octocov init +coverage: + acceptable: 80% + #exclude: + badge: + path: docs/coverage.svg +diff: + datastores: + - artifact://${GITHUB_REPOSITORY} +comment: + if: is_pull_request +report: + if: is_default_branch + datastores: + - artifact://${GITHUB_REPOSITORY} +codeToTestRatio: + badge: + path: docs/ratio.svg +testExecutionTime: + badge: + path: docs/time.svg diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..7fb86f2 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1 @@ +Please approach others with respect. That is everything. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..35dedec --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,11 @@ +## Contributing as a Developer +- When creating a bug report: Please follow the template and provide detailed information. +- When fixing a feature: Create a Pull Request (PR) with accompanying test code. +- When adding a feature: First, propose the feature in an Issue. + +## Contributing Outside of Coding +The following actions help boost my motivation: + +- Giving a GitHub Star +- Promoting the application +- Becoming a GitHub Sponsor diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..164b549 --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +APP = coincheck +VERSION = $(shell git describe --tags --abbrev=0) +GIT_REVISION := $(shell git rev-parse HEAD) +GO = go +GO_BUILD = $(GO) build +GO_TEST = $(GO) test +GO_TOOL = $(GO) tool +GOOS = "" +GOARCH = "" +GO_PKGROOT = ./... +GO_PACKAGES = $(shell $(GO_LIST) $(GO_PKGROOT)) +GO_LDFLAGS = + +.DEFAULT_GOAL := help +help: ## Show help message + @grep -E '^[0-9a-zA-Z_-]+[[:blank:]]*:.*?## .*$$' $(MAKEFILE_LIST) | sort \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[1;32m%-15s\033[0m %s\n", $$1, $$2}' + +.PHONY: tools +tools: ## Install dependency tools + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + +.PHONY: test +test: ## Run unit test + env GOOS=$(GOOS) $(GO_TEST) -cover -coverpkg=$(GO_PKGROOT) -coverprofile=coverage.out $(GO_PKGROOT) + $(GO_TOOL) cover -html=coverage.out -o coverage.html + +.PHONY: clean +clean: ## Clean project + -rm -rf coverage.out coverage.html + +.PHONY: lint +lint: ## Run lint + golangci-lint --config .golangci.yml run diff --git a/README.md b/README.md index 017bce5..b02ba3b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1 @@ -# coincheck -coincheck - coincheck public & private API client written in Go. +# coincheck public & private API client diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..2b75a05 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover any security-related issues or vulnerabilities, please contact us at [n.chika156@gmail.com](mailto:n.chika156@gmail.com). We appreciate your responsible disclosure and will work with you to address the issue promptly. + +## Supported Versions + +We recommend using the latest release for the most up-to-date and secure experience. Security updates are provided for the latest stable version. + +## Security Policy + +- Security issues are treated with the highest priority. +- We follow responsible disclosure practices. +- Fixes for security vulnerabilities will be provided in a timely manner. + +## Acknowledgments + +We would like to thank the security researchers and contributors who responsibly report security issues and work with us to make our project more secure. + +Thank you for your help in making our project safe and secure for everyone. diff --git a/coincheck.go b/coincheck.go new file mode 100644 index 0000000..25e42a7 --- /dev/null +++ b/coincheck.go @@ -0,0 +1,107 @@ +// Package coincheck provides a client for using the coincheck API. +// Ref. https://coincheck.com/documents/exchange/api +// +// The coincheck allows roughly two kinds of APIs; Public API and Private API. +// Public API allows you to browse order status and order book. +// Private API allows you to create and cancel new orders, and to check your balance. +// If you use Private API, you need to get your API key and API secret from the coincheck website. +package coincheck + +import ( + "context" + "io" + "net/http" + "net/url" +) + +const ( + // BaseURL is the base URL for the coincheck API. + BaseURL = "https://coincheck.com" +) + +// Client represents a coincheck client. +type Client struct { + // client is HTTP client that used to communicate with the Coincheck API. + client *http.Client + // baseURL is the base URL for the coincheck API. + baseURL *url.URL + // credentials is the credentials used to authenticate with the coincheck API. + credentials *credentials +} + +// NewClient returns a new coincheck client. +func NewClient(opts ...Option) (*Client, error) { + c := &Client{ + client: http.DefaultClient, + } + + baseURL, err := url.Parse(BaseURL) + if err != nil { + return nil, withPrefixError(err) + } + c.baseURL = baseURL + + for _, opt := range opts { + if err := opt(c); err != nil { + return nil, err + } + } + return c, nil +} + +// hasCredentials returns true if the client has credentials. +func (c *Client) hasCredentials() bool { + return c.credentials != nil +} + +// setAuthHeaders sets the authentication headers to the request. +// If you use Private API, you need to get your API key and API secret from the coincheck website. +func (c *Client) setAuthHeaders(req *http.Request, body string) error { + if !c.hasCredentials() { + return ErrNoCredentials + } + + headers, err := c.credentials.generateRequestHeaders(req.URL, body) + if err != nil { + return withPrefixError(err) + } + req.Header.Set("ACCESS-KEY", headers.AccessKey) + req.Header.Set("ACCESS-NONCE", headers.AccessNonce) + req.Header.Set("ACCESS-SIGNATURE", headers.AccessSignature) + return nil +} + +// createRequestInput represents the input parameters for createRequest. +type createRequestInput struct { + method string // HTTP method (e.g. GET, POST) + path string // API path (e.g. /api/orders) + body io.Reader // Request body. If you don't need it, set nil. + queryParam map[string]string // Query parameters (e.g. {"pair": "btc_jpy"}) If you don't need it, set nil. +} + +// createRequest creates a new HTTP request. +func (c *Client) createRequest(ctx context.Context, input createRequestInput) (*http.Request, error) { + u, err := url.JoinPath(c.baseURL.String(), input.path) + if err != nil { + return nil, withPrefixError(err) + } + + endpoint, err := url.Parse(u) + if err != nil { + return nil, withPrefixError(err) + } + + if input.queryParam != nil { + q := endpoint.Query() + for k, v := range input.queryParam { + q.Set(k, v) + } + endpoint.RawQuery = q.Encode() + } + + req, err := http.NewRequestWithContext(ctx, input.method, endpoint.String(), input.body) + if err != nil { + return nil, withPrefixError(err) + } + return req, nil +} diff --git a/credentials.go b/credentials.go new file mode 100644 index 0000000..de1fd5b --- /dev/null +++ b/credentials.go @@ -0,0 +1,53 @@ +package coincheck + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/url" + "time" +) + +// credentials represents the credentials used to authenticate with the coincheck API. +// If you use Private API, you need to get your API key and API secret from the coincheck website. +type credentials struct { + // key is the API key. + key string + // secret is the API secret. + secret string +} + +// requestHeaderParam represents the parameters to be included in the request header. +// +// For requests that require authentication, you have to add information below to HTTP Header. +// ACCESS-KEY: Access key you genertaed at API key +// ACCESS-NONCE: Positive integer will increase every time you send request (managed for each API key). A common practice is to use UNIX time. Maximum value is 9223372036854775807. +// ACCESS-SIGNATURE: HMAC-SHA256 encoded message containing, ACCESS-NONCE, Request URL and Request body by using API key. +type requestHeaderParam struct { + // AccessKey is the API key. + AccessKey string + // AccessNonce is the positive integer that will increase every time you send a request. + // A common practice is to use UNIX time. Maximum value is 9223372036854775807. + AccessNonce string + // AccessSignature is the HMAC-SHA256 encoded message containing, ACCESS-NONCE, Request URL and Request body by using API key. + AccessSignature string +} + +// generateRequestHeaders generates requestHeaderParam struct. +func (c *credentials) generateRequestHeaders(requestURL *url.URL, body string) (*requestHeaderParam, error) { + nonce := time.Now().Unix() + message := fmt.Sprintf("%d%s%s", nonce, requestURL, body) + + h := hmac.New(sha256.New, []byte(c.secret)) + if _, err := h.Write([]byte(message)); err != nil { + return nil, ErrGenerateRequestHeaders + } + signature := hex.EncodeToString(h.Sum(nil)) + + return &requestHeaderParam{ + AccessKey: c.key, + AccessNonce: fmt.Sprintf("%d", nonce), + AccessSignature: signature, + }, nil +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..f0ca49f --- /dev/null +++ b/errors.go @@ -0,0 +1,21 @@ +package coincheck + +import "errors" + +var ( + // ErrNilHTTPClient means specified http client is nil. + ErrNilHTTPClient = errors.New("coincheck: specified HTTP client is nil") + // ErrInvalidBaseURL means specified base URL is invalid. + ErrInvalidBaseURL = errors.New("coincheck: specified base URL is invalid") + // ErrGenerateRequestHeaders means failed to generate request headers. + // If this error occurs, you should check the API key and API secret. + ErrGenerateRequestHeaders = errors.New("coincheck: failed to generate request headers") + // ErrNoCredentials means specified credentials is nil. + ErrNoCredentials = errors.New("coincheck: specified credentials is nil") +) + +// withPrefixError returns an error with the package prefix. +func withPrefixError(err error) error { + const prefix = "coincheck" + return errors.New(prefix + ": " + err.Error()) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..771023f --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/nao1215/coincheck + +go 1.20 + +require github.com/google/go-cmp v0.6.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5a8d551 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= diff --git a/option.go b/option.go new file mode 100644 index 0000000..a3790e6 --- /dev/null +++ b/option.go @@ -0,0 +1,45 @@ +package coincheck + +import ( + "fmt" + "net/http" + "net/url" +) + +// Option is a parameter to be specified when creating a Coincheck +// client to configure the http client details. +type Option func(*Client) error + +// WithHTTPClient sets the HTTP client which will be used to make requests. +func WithHTTPClient(client *http.Client) Option { + return func(c *Client) error { + if client == nil { + return ErrNilHTTPClient + } + c.client = client + return nil + } +} + +// WithBaseURL sets the base URL for the client to make requests to. +func WithBaseURL(u string) Option { + return func(c *Client) error { + baseURL, err := url.Parse(u) + if err != nil { + return fmt.Errorf("%w: %s", ErrInvalidBaseURL, err.Error()) + } + c.baseURL = baseURL + return nil + } +} + +// WithCredentials sets the credentials to be used to authenticate with the Coincheck API. +func WithCredentials(key, secret string) Option { + return func(c *Client) error { + c.credentials = &credentials{ + key: key, + secret: secret, + } + return nil + } +} diff --git a/option_test.go b/option_test.go new file mode 100644 index 0000000..8dda4c9 --- /dev/null +++ b/option_test.go @@ -0,0 +1,57 @@ +package coincheck + +import ( + "errors" + "net/http" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestOptions(t *testing.T) { + t.Parallel() + + t.Run("WithHTTPClient sets the HTTP client which will be used to make requests", func(t *testing.T) { + t.Parallel() + + client := &http.Client{} + c, err := NewClient(WithHTTPClient(client)) + if err != nil { + t.Fatalf("NewClient returned unexpected error: %v", err) + } + + if diff := cmp.Diff(c.client, client); diff != "" { + printDiff(t, diff) + } + }) + + t.Run("WithHTTPClient returns an error if the HTTP client is nil", func(t *testing.T) { + t.Parallel() + + if _, err := NewClient(WithHTTPClient(nil)); !errors.Is(err, ErrNilHTTPClient) { + t.Errorf("error is not ErrNilHTTPClient: %v", err) + } + }) + + t.Run("WithBaseURL sets the base URL for the client to make requests to", func(t *testing.T) { + t.Parallel() + + baseURL := "https://example.com" + c, err := NewClient(WithBaseURL(baseURL)) + if err != nil { + t.Fatalf("NewClient returned unexpected error: %v", err) + } + + if diff := cmp.Diff(c.baseURL.String(), baseURL); diff != "" { + printDiff(t, diff) + } + }) + + t.Run("WithBaseURL returns an error if the base URL is invalid", func(t *testing.T) { + t.Parallel() + + if _, err := NewClient(WithBaseURL(":")); !errors.Is(err, ErrInvalidBaseURL) { + t.Errorf("error is not ErrInvalidBaseURL: %v", err) + } + }) +} diff --git a/test_helper_test.go b/test_helper_test.go new file mode 100644 index 0000000..3249bee --- /dev/null +++ b/test_helper_test.go @@ -0,0 +1,9 @@ +package coincheck + +import "testing" + +// printDiff prints the gocmp diff. +func printDiff(t *testing.T, diff string) { + t.Helper() + t.Errorf("differs: (-want +got)\n%s", diff) +} diff --git a/ticker.go b/ticker.go new file mode 100644 index 0000000..dbcd865 --- /dev/null +++ b/ticker.go @@ -0,0 +1,94 @@ +package coincheck + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +// Pair represents the pair of the currency. +type Pair string + +// String returns the string representation of the pair. +func (p Pair) String() string { + return string(p) +} + +const ( + // PairBTCJPY is the pair of Bitcoin and Japanese Yen. + PairBTCJPY Pair = "btc_jpy" + // PairETHJPY is the pair of Ethereum and Japanese Yen. + PairETHJPY Pair = "eth_jpy" + // PairLskJPY is the pair of Lisk and Japanese Yen. + PairLskJPY Pair = "lsk_jpy" + // PairMonaJPY is the pair of MonaCoin and Japanese Yen. + PairMonaJPY Pair = "mona_jpy" + // PairPltJPY is the pair of Palette Token and Japanese Yen. + PairPltJPY Pair = "plt_jpy" + // PairFnctJPY is the pair of FiNANCiE and Japanese Yen. + PairFnctJPY Pair = "fnct_jpy" + // PairDaiJPY is the pair of DAI and Japanese Yen. + PairDaiJPY Pair = "dai_jpy" + // PairWbtcJPY is the pair of Wrapped Bitcoin and Japanese Yen. + PairWbtcJPY Pair = "wbtc_jpy" + // PairBrilJPY is the pair of Brilliantcrypto and Japanese Yen. + PairBrilJPY Pair = "bril_jpy" +) + +// GetTickerInput represents the input parameter for GetTicker. +type GetTickerInput struct { + // Pair is the pair of the currency. e.g. btc_jpy. + Pair Pair +} + +// GetTickerResponse represents the output from GetTicker. +type GetTickerResponse struct { + // Last is latest quote. + Last uint64 `json:"last"` + // Bid is current highest buying order. + Bid uint64 `json:"bid"` + // Ask is current lowest selling order. + Ask uint64 `json:"ask"` + // High is highest price in last 24 hours. + High uint64 `json:"high"` + // Low is lowest price in last 24 hours. + Low uint64 `json:"low"` + // Volume is trading Volume in last 24 hours. + Volume string `json:"volume"` + // Timestamp is current time. It's Unix Timestamp. + Timestamp uint64 `json:"timestamp"` +} + +// GetTicker check latest ticker information. +// API: GET /api/ticker +// Visibility: Public +// If pair is not specified, you can get the information of btc_jpy. +func (c *Client) GetTicker(ctx context.Context, input *GetTickerInput) (*GetTickerResponse, error) { + req, err := c.createRequest(ctx, createRequestInput{ + method: http.MethodGet, + path: "/api/ticker", + queryParam: map[string]string{ + "pair": string(input.Pair), + }, + }) + if err != nil { + return nil, err + } + + resp, err := c.client.Do(req) + if err != nil { + return nil, withPrefixError(err) + } + defer resp.Body.Close() //nolint: errcheck // ignore error + + if resp.StatusCode != http.StatusOK { + return nil, withPrefixError(fmt.Errorf("unexpected status code=%d", resp.StatusCode)) + } + + var output GetTickerResponse + if err := json.NewDecoder(resp.Body).Decode(&output); err != nil { + return nil, withPrefixError(err) + } + return &output, nil +} diff --git a/ticker_test.go b/ticker_test.go new file mode 100644 index 0000000..a9465d7 --- /dev/null +++ b/ticker_test.go @@ -0,0 +1,98 @@ +package coincheck + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestClientGetTicker(t *testing.T) { + t.Run("In the case of a successful GET /api/ticker request", func(t *testing.T) { + // Create a new test server + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wantMethod := http.MethodGet + if diff := cmp.Diff(wantMethod, r.Method); diff != "" { + printDiff(t, diff) + } + + wantEndpoint := "/api/ticker" + if diff := cmp.Diff(wantEndpoint, r.URL.Path); diff != "" { + printDiff(t, diff) + } + + wantPair := PairETHJPY + if got := r.URL.Query().Get("pair"); got != wantPair.String() { + t.Errorf("pair: got %v, want %v", got, wantPair) + } + + result := GetTickerResponse{ + Last: 1000000, + Bid: 999000, + Ask: 1001000, + High: 1002000, + Low: 998000, + Volume: "100.0", + Timestamp: 1609459200, + } + if err := json.NewEncoder(w).Encode(result); err != nil { + t.Fatal(err) + } + })) + defer testServer.Close() + + // Create a new client + client, err := NewClient(WithBaseURL(testServer.URL)) + if err != nil { + t.Fatal(err) + } + + // Start testing + input := &GetTickerInput{ + Pair: PairETHJPY, + } + got, err := client.GetTicker(context.Background(), input) + if err != nil { + t.Fatal(err) + } + + // Check the result + want := &GetTickerResponse{ + Last: 1000000, + Bid: 999000, + Ask: 1001000, + High: 1002000, + Low: 998000, + Volume: "100.0", + Timestamp: 1609459200, + } + if diff := cmp.Diff(want, got); diff != "" { + printDiff(t, diff) + } + }) + + t.Run("In the case of a failed GET /api/ticker request", func(t *testing.T) { + // Create a new test server + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer testServer.Close() + + // Create a new client + client, err := NewClient(WithBaseURL(testServer.URL)) + if err != nil { + t.Fatal(err) + } + + // Start testing + input := &GetTickerInput{ + Pair: PairETHJPY, + } + if _, err = client.GetTicker(context.Background(), input); err == nil { + t.Error("want error, but got nil") + } + }) +} diff --git a/withdraw.go b/withdraw.go new file mode 100644 index 0000000..ade37dd --- /dev/null +++ b/withdraw.go @@ -0,0 +1,64 @@ +package coincheck + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +// GetBankAccountsResponse represents the response from the GetBankAccounts API. +type GetBankAccountsResponse struct { + // Success is a boolean value that indicates the success of the API call. + Success bool `json:"success"` + // Data is a list of bank accounts. + Data []BankAccount +} + +// BankAccount represents a bank account. +type BankAccount struct { + // ID is the bank account ID. + ID int `json:"id"` + // BankName is the bank name. + BankName string `json:"bank_name"` + // BranchName is the branch name. + BranchName string `json:"branch_name"` + // BankAccountType is the bank account type. + BankAccountType string `json:"bank_account_type"` + // Number is the bank account number. + Number string `json:"number"` + // Name is the bank account name. + Name string `json:"name"` +} + +// GetBankAccounts returns a list of bank account you registered (withdrawal). +// API: GET /api/bank_accounts +// Visibility: Private +func (c *Client) GetBankAccounts(ctx context.Context) (*GetBankAccountsResponse, error) { + req, err := c.createRequest(ctx, createRequestInput{ + method: http.MethodGet, + path: "/api/bank_accounts", + }) + if err != nil { + return nil, err + } + if err := c.setAuthHeaders(req, ""); err != nil { + return nil, err + } + + resp, err := c.client.Do(req) + if err != nil { + return nil, withPrefixError(err) + } + defer resp.Body.Close() //nolint: errcheck // ignore error + + if resp.StatusCode != http.StatusOK { + return nil, withPrefixError(fmt.Errorf("unexpected status code=%d", resp.StatusCode)) + } + + var output GetBankAccountsResponse + if err := json.NewDecoder(resp.Body).Decode(&output); err != nil { + return nil, withPrefixError(err) + } + return &output, nil +} diff --git a/withdraw_test.go b/withdraw_test.go new file mode 100644 index 0000000..04227b3 --- /dev/null +++ b/withdraw_test.go @@ -0,0 +1,127 @@ +package coincheck + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestClientGetBankAccounts(t *testing.T) { + t.Run("GetBankAccounts returns a list of bank accounts", func(t *testing.T) { + // Create a new test server + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wantMethod := http.MethodGet + if diff := cmp.Diff(wantMethod, r.Method); diff != "" { + printDiff(t, diff) + } + + wantEndpoint := "/api/bank_accounts" + if diff := cmp.Diff(wantEndpoint, r.URL.Path); diff != "" { + printDiff(t, diff) + } + + result := GetBankAccountsResponse{ + Success: true, + Data: []BankAccount{ + { + ID: 1, + BankName: "bank_name_1", + BranchName: "branch_name_1", + BankAccountType: "bank_account_type_1", + Number: "number_1", + Name: "name_1", + }, + { + ID: 2, + BankName: "bank_name_2", + BranchName: "branch_name_2", + BankAccountType: "bank_account_type_2", + Number: "number_2", + Name: "name_2", + }, + }, + } + if err := json.NewEncoder(w).Encode(result); err != nil { + t.Fatal(err) + } + })) + + // Create a new client + client, err := NewClient( + WithBaseURL(testServer.URL), + WithCredentials("api_key", "api_secret"), + ) + if err != nil { + t.Fatal(err) + } + + // Start testing + got, err := client.GetBankAccounts(context.Background()) + if err != nil { + t.Fatal(err) + } + + // Check the result + want := &GetBankAccountsResponse{ + Success: true, + Data: []BankAccount{ + { + ID: 1, + BankName: "bank_name_1", + BranchName: "branch_name_1", + BankAccountType: "bank_account_type_1", + Number: "number_1", + Name: "name_1", + }, + { + ID: 2, + BankName: "bank_name_2", + BranchName: "branch_name_2", + BankAccountType: "bank_account_type_2", + Number: "number_2", + Name: "name_2", + }, + }, + } + if diff := cmp.Diff(want, got); diff != "" { + printDiff(t, diff) + } + }) + + t.Run("GetBankAccounts returns an error if the server return internal server error", func(t *testing.T) { + // Create a new test server + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + + // Create a new client + client, err := NewClient(WithBaseURL(testServer.URL)) + if err != nil { + t.Fatal(err) + } + + // Start testing + if _, err = client.GetBankAccounts(context.Background()); err == nil { + t.Error("want error, but got nil") + } + }) + + t.Run("GetBankAccounts returns an error if client does not set credentials", func(t *testing.T) { + // Create a new client + client, err := NewClient(WithBaseURL("https://example.com")) + if err != nil { + t.Fatal(err) + } + + // Start testing + _, err = client.GetBankAccounts(context.Background()) + if !errors.Is(err, ErrNoCredentials) { + t.Errorf("error is not ErrNoCredentials: %v", err) + } + }) +}