diff --git a/.gitignore b/.gitignore index 3b735ec..5593560 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ # Go workspace file go.work + +.idea +.DS_Store diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..81dad0b --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,102 @@ +linters-settings: + funlen: + lines: 130 + statements: 50 + goconst: + min-len: 2 + min-occurrences: 2 + gocritic: + enabled-tags: + - diagnostic + - opinionated + - performance + - style + disabled-checks: + - dupImport # https://github.com/go-critic/go-critic/issues/845 + - wrapperFunc + gocyclo: + min-complexity: 15 + godot: + # comments to be checked: `declarations`, `toplevel`, or `all` + scope: declarations + # list of regexps for excluding particular comment lines from check + exclude: + # example: exclude comments which contain numbers + # - '[0-9]+' + # check that each sentence starts with a capital letter + capital: false + govet: + check-shadowing: true + lll: + line-length: 200 + maligned: + suggest-new: true + misspell: + locale: UK + nolintlint: + allow-leading-space: true # don't require machine-readable nolint directives (i.e. with no leading space) + allow-unused: false # report any unused nolint directives + require-explanation: false # don't require an explanation for nolint directives + require-specific: false # don't require nolint directives to be specific about which linter is being skipped + staticcheck: + checks: ["all", -SA5001] +linters: + # please, do not use `enable-all`: it's deprecated and will be removed soon. + # inverted configuration with `enable-all` and `disable` is not scalable during updates of golangci-lint + disable-all: true + enable: + #- deadcode + - depguard + - dogsled + #- errcheck + - exportloopref + - exhaustive + - funlen + - goconst + - gocritic + - gocyclo + - godot + - gofmt + - goimports + - goprintffuncname + - gosimple + - govet + - ineffassign + - lll + - misspell + - nakedret + - noctx + - nolintlint + - rowserrcheck + - staticcheck + - structcheck + - stylecheck + - typecheck + - unconvert + - unparam + - unused + - varcheck + - whitespace + + # don't enable: + # - asciicheck + # - bodyclose + # - scopelint + # - gochecknoglobals + # - gocognit + # - godox + # - goerr113 + # - interfacer + # - maligned + # - nestif + # - prealloc + # - testpackage + # - revive + # - wsl + +issues: + # Excluding configuration per-path, per-linter, per-text and per-source + exclude-rules: + - path: _test\.go + linters: + - gomnd \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cfac82d --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +.DEFAULT_GOAL := ci + +ci: lint test + +test: + @go test -race -failfast -covermode=atomic ./... + +lint: + @golangci-lint run ./... \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b519bc6 --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# OXR 💹 + +## Install + +```shell +go get github.com/jamieaitken/oxr +``` + +## How to use + +### Initialise your client + +```go +doer := http.DefaultClient +c := oxr.New(oxr.WithAppID("your_app_id"), oxr.WithDoer(doer)) +``` + +### Latest Rates + +Latest retrieves the latest exchange rates available from the Open Exchange +Rates [API](https://docs.openexchangerates.org/docs/latest-json) + +```go +doer := http.DefaultClient +c := oxr.New(oxr.WithAppID("your_app_id"), oxr.WithDoer(doer)) + +latestRates, err := c.Latest(context.Background(), oxr.LatestForBaseCurrency("GBP")) +``` + +### Historical + +Historical retrieves historical exchange rates for any date available from the Open Exchange Rates +[API](https://docs.openexchangerates.org/docs/historical-json), currently going back to 1st January 1999. + +```go +doer := http.DefaultClient +c := oxr.New(oxr.WithAppID("your_app_id"), oxr.WithDoer(doer)) + +historicalRates, err := c.Historical( +context.Background(), +oxr.HistoricalForDate(time.Date(2022, 03, 10, 12, 00, 00, 00, time.UTC)), +oxr.HistoricalForBaseCurrency("USD"), +) +``` + +### Currencies + +Currencies retrieves the list of all currency symbols available from the Open Exchange +Rates [API](https://docs.openexchangerates.org/docs/currencies-json), along with their full names. + +```go +doer := http.DefaultClient +c := oxr.New(oxr.WithAppID("your_app_id"), oxr.WithDoer(doer)) + +currencies, err := c.Currencies(context.Background()) +``` + +### Time Series + +TimeSeries retrieves historical exchange rates for a given time period, where available, using the time series / bulk +download [API](https://docs.openexchangerates.org/docs/time-series-json) endpoint. + +```go +doer := http.DefaultClient +c := oxr.New(oxr.WithAppID("your_app_id"), oxr.WithDoer(doer)) + +currencies, err := c.TimeSeries( +context.Background(), +oxr.TimeSeriesForStartDate(time.Date(2013, 01, 01, 00, 00, 00, 00, time.UTC)), +oxr.TimeSeriesForEndDate(time.Date(2013, 01, 31, 00, 00, 00, 00, time.UTC)), +oxr.TimeSeriesForBaseCurrency("AUD"), +oxr.TimeSeriesForDestinationCurrencies([]string{"BTC", "EUR", "HKD"}), +) +``` + +### Convert + +Convert any money value from one currency to another at the +latest [API](https://docs.openexchangerates.org/docs/convert) rates. + +```go +doer := http.DefaultClient +c := oxr.New(oxr.WithAppID("your_app_id"), oxr.WithDoer(doer)) + +currencies, err := c.Convert( +context.Background(), +oxr.ConvertWithValue(100.12), +oxr.ConvertForBaseCurrency("GBP"), +oxr.ConvertForDestinationCurrency("USD"), +) +``` + +### Open High Low Close (OHLC) + +OpenHighLowClose [retrieves](https://docs.openexchangerates.org/docs/ohlc-json) historical Open, High Low, Close (OHLC) +and Average exchange rates for a given time period, ranging from 1 month to 1 minute, where available. + +```go +doer := http.DefaultClient +c := oxr.New(oxr.WithAppID("your_app_id"), oxr.WithDoer(doer)) + +currencies, err := c.OpenHighLowClose( +context.Background(), +oxr.OHLCForBaseCurrency("USD"), +oxr.OHLCForPeriod(oxr.ThirtyMinute), +oxr.OHLCForDestinationCurrencies([]string{"GBP", "EUR"}), +oxr.OHLCForStartTime(time.Date(2022, 3, 15, 13, 00, 00, 00, time.UTC)), +) +``` + +### Usage + +Usage [retrieves](https://docs.openexchangerates.org/docs/usage-json) basic plan information and usage statistics for an +Open Exchange Rates App ID. + +```go +doer := http.DefaultClient +c := oxr.New(oxr.WithAppID("your_app_id"), oxr.WithDoer(doer)) + +currencies, err := c.Usage(context.Background()) +``` + +## License +[MIT](https://choosealicense.com/licenses/mit/) \ No newline at end of file diff --git a/client.go b/client.go new file mode 100644 index 0000000..49df18a --- /dev/null +++ b/client.go @@ -0,0 +1,368 @@ +package oxr + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "time" +) + +const ( + timeFormat = "2006-01-02" + basePath = "https://openexchangerates.org/api/" +) + +var ( + ErrBadResponse = errors.New("failed to receive successful response") +) + +// Doer sends a http.Request and returns a http.Response. +type Doer interface { + Do(r *http.Request) (*http.Response, error) +} + +// Client is responsible for all interactions between OXR. +type Client struct { + appID string + doer Doer + baseURL string +} + +// New instantiates a Client. +func New(opts ...ClientOption) Client { + c := Client{ + baseURL: basePath, + } + + for _, opt := range opts { + opt(&c) + } + + return c +} + +// Latest retrieves the latest exchange rates available from the Open Exchange Rates API. +// https://docs.openexchangerates.org/docs/latest-json +func (c Client) Latest(ctx context.Context, opts ...LatestOption) (LatestRatesResponse, error) { + r := latestParams{} + + for _, opt := range opts { + opt(&r) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%slatest.json", c.baseURL), http.NoBody) + if err != nil { + return LatestRatesResponse{}, err + } + + v := req.URL.Query() + + v.Add("app_id", c.appID) + v.Add("prettyprint", strconv.FormatBool(r.prettyPrint)) + if r.baseCurrency != "" { + v.Add("base", r.baseCurrency) + } + if r.destinationCurrencies != "" { + v.Add("symbols", r.destinationCurrencies) + } + v.Add("show_alternative", strconv.FormatBool(r.showAlternative)) + + req.URL.RawQuery = v.Encode() + + res, err := c.doer.Do(req) + defer res.Body.Close() + if err != nil { + return LatestRatesResponse{}, err + } + + if res.StatusCode != http.StatusOK { + return LatestRatesResponse{}, fmt.Errorf("status received: %v: %w", res.StatusCode, ErrBadResponse) + } + + var resData LatestRatesResponse + err = json.NewDecoder(res.Body).Decode(&resData) + if err != nil { + return LatestRatesResponse{}, err + } + + return resData, nil +} + +// Historical retrieves historical exchange rates for any date available from the Open Exchange Rates API, currently +// going back to 1st January 1999. +// https://docs.openexchangerates.org/docs/historical-json +func (c Client) Historical(ctx context.Context, opts ...HistoricalOption) (HistoricalRatesResponse, error) { + r := historicalParams{} + + for _, opt := range opts { + opt(&r) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + fmt.Sprintf("%shistorical/%s.json", c.baseURL, r.date.Format(timeFormat)), http.NoBody) + if err != nil { + return HistoricalRatesResponse{}, err + } + + v := req.URL.Query() + v.Add("app_id", c.appID) + v.Add("prettyprint", strconv.FormatBool(r.prettyPrint)) + v.Add("show_alternative", strconv.FormatBool(r.showAlternative)) + if r.baseCurrency != "" { + v.Add("base", r.baseCurrency) + } + if r.destinationCurrencies != "" { + v.Add("symbols", r.destinationCurrencies) + } + + req.URL.RawQuery = v.Encode() + + res, err := c.doer.Do(req) + defer res.Body.Close() + + if err != nil { + return HistoricalRatesResponse{}, err + } + + if res.StatusCode != http.StatusOK { + return HistoricalRatesResponse{}, fmt.Errorf("status received: %v: %w", res.StatusCode, ErrBadResponse) + } + + var resData HistoricalRatesResponse + err = json.NewDecoder(res.Body).Decode(&resData) + if err != nil { + return HistoricalRatesResponse{}, err + } + + return resData, nil +} + +// Currencies retrieves the list of all currency symbols available from the Open Exchange Rates API, along with their +// full names. +// https://docs.openexchangerates.org/docs/currencies-json +func (c Client) Currencies(ctx context.Context, opts ...CurrenciesOption) (CurrenciesResponse, error) { + r := currenciesParams{} + + for _, opt := range opts { + opt(&r) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + fmt.Sprintf("%scurrencies.json", c.baseURL), http.NoBody) + if err != nil { + return CurrenciesResponse{}, err + } + + v := req.URL.Query() + v.Add("app_id", c.appID) + v.Add("show_inactive", strconv.FormatBool(r.showInactive)) + v.Add("prettyprint", strconv.FormatBool(r.prettyPrint)) + v.Add("show_alternative", strconv.FormatBool(r.showAlternative)) + + req.URL.RawQuery = v.Encode() + + res, err := c.doer.Do(req) + defer res.Body.Close() + + if err != nil { + return CurrenciesResponse{}, err + } + + if res.StatusCode != http.StatusOK { + return CurrenciesResponse{}, fmt.Errorf("status received: %v: %w", res.StatusCode, ErrBadResponse) + } + + var resData CurrenciesResponse + err = json.NewDecoder(res.Body).Decode(&resData.Currencies) + if err != nil { + return CurrenciesResponse{}, err + } + + return resData, nil +} + +// TimeSeries retrieves historical exchange rates for a given time period, where available, using the time series / bulk +// download API endpoint. +// https://docs.openexchangerates.org/docs/time-series-json +func (c Client) TimeSeries(ctx context.Context, opts ...TimeSeriesOption) (TimeSeriesResponse, error) { + r := timeSeriesParams{} + + for _, opt := range opts { + opt(&r) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + fmt.Sprintf("%stime-series.json", c.baseURL), http.NoBody) + if err != nil { + return TimeSeriesResponse{}, err + } + + v := req.URL.Query() + v.Add("app_id", c.appID) + v.Add("prettyprint", strconv.FormatBool(r.prettyPrint)) + v.Add("show_alternative", strconv.FormatBool(r.showAlternative)) + v.Add("start", r.startDate.Format(timeFormat)) + v.Add("end", r.endDate.Format(timeFormat)) + if r.destinationCurrencies != "" { + v.Add("symbols", r.destinationCurrencies) + } + if r.baseCurrency != "" { + v.Add("base", r.baseCurrency) + } + + req.URL.RawQuery = v.Encode() + + res, err := c.doer.Do(req) + defer res.Body.Close() + + if err != nil { + return TimeSeriesResponse{}, err + } + + if res.StatusCode != http.StatusOK { + return TimeSeriesResponse{}, fmt.Errorf("status received: %v: %w", res.StatusCode, ErrBadResponse) + } + + var resData TimeSeriesResponse + err = json.NewDecoder(res.Body).Decode(&resData) + if err != nil { + return TimeSeriesResponse{}, err + } + + return resData, nil +} + +// Convert any money value from one currency to another at the latest API rates. +// https://docs.openexchangerates.org/docs/convert +func (c Client) Convert(ctx context.Context, opts ...ConvertOption) (ConversionResponse, error) { + r := convertParams{} + + for _, opt := range opts { + opt(&r) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + fmt.Sprintf("%sconvert/%v/%s/%s", c.baseURL, r.value, r.baseCurrency, r.destinationCurrency), http.NoBody) + if err != nil { + return ConversionResponse{}, err + } + + v := req.URL.Query() + v.Add("app_id", c.appID) + v.Add("prettyprint", strconv.FormatBool(r.prettyPrint)) + + req.URL.RawQuery = v.Encode() + + res, err := c.doer.Do(req) + defer res.Body.Close() + + if err != nil { + return ConversionResponse{}, err + } + + if res.StatusCode != http.StatusOK { + return ConversionResponse{}, fmt.Errorf("status received: %v: %w", res.StatusCode, ErrBadResponse) + } + + var resData ConversionResponse + err = json.NewDecoder(res.Body).Decode(&resData) + if err != nil { + return ConversionResponse{}, err + } + + return resData, nil +} + +// OpenHighLowClose retrieves historical Open, High Low, Close (OHLC) and Average exchange rates for a given time period, +// ranging from 1 month to 1 minute, where available. +// https://docs.openexchangerates.org/docs/ohlc-json +func (c Client) OpenHighLowClose(ctx context.Context, opts ...OHLCOption) (OHLCResponse, error) { + r := ohlcParams{} + + for _, opt := range opts { + opt(&r) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + fmt.Sprintf("%sohlc.json", c.baseURL), http.NoBody) + if err != nil { + return OHLCResponse{}, err + } + + v := req.URL.Query() + v.Add("app_id", c.appID) + v.Add("prettyprint", strconv.FormatBool(r.prettyPrint)) + v.Add("start_date", r.startTime.Format(time.RFC3339)) + v.Add("period", r.period.String()) + if r.destinationCurrencies != "" { + v.Add("symbols", r.destinationCurrencies) + } + if r.baseCurrency != "" { + v.Add("base", r.baseCurrency) + } + + req.URL.RawQuery = v.Encode() + + res, err := c.doer.Do(req) + defer res.Body.Close() + + if err != nil { + return OHLCResponse{}, err + } + + if res.StatusCode != http.StatusOK { + return OHLCResponse{}, fmt.Errorf("status received: %v: %w", res.StatusCode, ErrBadResponse) + } + + var resData OHLCResponse + err = json.NewDecoder(res.Body).Decode(&resData) + if err != nil { + return OHLCResponse{}, err + } + + return resData, nil +} + +// Usage retrieves basic plan information and usage statistics for an Open Exchange Rates App ID. +// https://docs.openexchangerates.org/docs/usage-json +func (c Client) Usage(ctx context.Context, opts ...UsageOption) (UsageResponse, error) { + r := usageParams{} + + for _, opt := range opts { + opt(&r) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + fmt.Sprintf("%susage.json", c.baseURL), http.NoBody) + if err != nil { + return UsageResponse{}, err + } + + v := req.URL.Query() + v.Add("app_id", c.appID) + v.Add("prettyprint", strconv.FormatBool(r.prettyPrint)) + + req.URL.RawQuery = v.Encode() + + res, err := c.doer.Do(req) + defer res.Body.Close() + + if err != nil { + return UsageResponse{}, err + } + + if res.StatusCode != http.StatusOK { + return UsageResponse{}, fmt.Errorf("status received: %v: %w", res.StatusCode, ErrBadResponse) + } + + var resData UsageResponse + err = json.NewDecoder(res.Body).Decode(&resData) + if err != nil { + return UsageResponse{}, err + } + + return resData, nil +} diff --git a/client_opts.go b/client_opts.go new file mode 100644 index 0000000..22185f8 --- /dev/null +++ b/client_opts.go @@ -0,0 +1,18 @@ +package oxr + +// ClientOption allows a Client to be modified. +type ClientOption func(*Client) + +// WithAppID sets the Open Exchange App ID to be used. +func WithAppID(appID string) ClientOption { + return func(client *Client) { + client.appID = appID + } +} + +// WithDoer allows clients to specify what http.Client is to be used to perform requests. +func WithDoer(doer Doer) ClientOption { + return func(client *Client) { + client.doer = doer + } +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..c8dfd60 --- /dev/null +++ b/client_test.go @@ -0,0 +1,1064 @@ +package oxr_test + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/jamieaitken/oxr" +) + +func TestClient_Convert_Success(t *testing.T) { + tests := []struct { + name string + givenDoer *mockDoer + givenClientOpts []oxr.ClientOption + givenConvertOpts []oxr.ConvertOption + expectedURL string + expectedResult oxr.ConversionResponse + }{ + { + name: "given successful convert response, expect payload returned", + givenDoer: &mockDoer{ + GivenResponse: &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(successfulConversion())), + }, + }, + givenClientOpts: []oxr.ClientOption{ + oxr.WithAppID("test"), + }, + givenConvertOpts: []oxr.ConvertOption{ + oxr.ConvertWithValue(100.12), + oxr.ConvertForBaseCurrency("GBP"), + oxr.ConvertForDestinationCurrency("USD"), + }, + expectedURL: "https://openexchangerates.org/api/convert/100.12/GBP/USD?app_id=test&prettyprint=false", + expectedResult: oxr.ConversionResponse{ + Disclaimer: "https://openexchangerates.org/terms/", + License: "https://openexchangerates.org/license/", + Request: oxr.ConversionRequest{ + Query: "/convert/100.12/GBP/USD", + Amount: 100.12, + From: "GBP", + To: "USD", + }, + Meta: oxr.ConversionMeta{ + Timestamp: 1449885661, + Rate: 0.76, + }, + Response: 76.0912, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c := oxr.New(append(test.givenClientOpts, oxr.WithDoer(test.givenDoer))...) + + actual, err := c.Convert(context.Background(), test.givenConvertOpts...) + if err != nil { + t.Fatal(err) + } + + if !cmp.Equal(test.givenDoer.SpyURL, test.expectedURL) { + t.Fatal(cmp.Diff(test.givenDoer.SpyURL, test.expectedURL)) + } + + if !cmp.Equal(actual, test.expectedResult) { + t.Fatal(cmp.Diff(actual, test.expectedResult)) + } + }) + } +} + +func TestClient_Convert_Fail(t *testing.T) { + tests := []struct { + name string + givenDoer *mockDoer + givenClientOpts []oxr.ClientOption + givenConvertOpts []oxr.ConvertOption + expectedURL string + expectedError error + }{ + { + name: "given doer error, expect error returned", + givenDoer: &mockDoer{ + GivenResponse: &http.Response{ + Body: io.NopCloser(strings.NewReader("")), + }, + GivenError: http.ErrBodyNotAllowed, + }, + givenClientOpts: []oxr.ClientOption{ + oxr.WithAppID("test"), + }, + givenConvertOpts: []oxr.ConvertOption{ + oxr.ConvertWithValue(100.12), + oxr.ConvertForBaseCurrency("GBP"), + oxr.ConvertForDestinationCurrency("USD"), + }, + expectedURL: "https://openexchangerates.org/api/convert/100.12/GBP/USD?app_id=test&prettyprint=false", + expectedError: http.ErrBodyNotAllowed, + }, + { + name: "given non 200 response, expect error returned", + givenDoer: &mockDoer{ + GivenResponse: &http.Response{ + Status: http.StatusText(http.StatusForbidden), + StatusCode: http.StatusForbidden, + Body: io.NopCloser(strings.NewReader("")), + }, + }, + givenClientOpts: []oxr.ClientOption{ + oxr.WithAppID("test"), + }, + givenConvertOpts: []oxr.ConvertOption{ + oxr.ConvertWithValue(100.12), + oxr.ConvertForBaseCurrency("GBP"), + oxr.ConvertForDestinationCurrency("USD"), + }, + expectedURL: "https://openexchangerates.org/api/convert/100.12/GBP/USD?app_id=test&prettyprint=false", + expectedError: oxr.ErrBadResponse, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c := oxr.New(append(test.givenClientOpts, oxr.WithDoer(test.givenDoer))...) + + _, err := c.Convert(context.Background(), test.givenConvertOpts...) + if err == nil { + t.Fatalf("expected %v, got nil", err) + } + + if !cmp.Equal(test.givenDoer.SpyURL, test.expectedURL) { + t.Fatal(cmp.Diff(test.givenDoer.SpyURL, test.expectedURL)) + } + + if !cmp.Equal(err, test.expectedError, cmpopts.EquateErrors()) { + t.Fatal(cmp.Diff(err, test.expectedError, cmpopts.EquateErrors())) + } + }) + } +} + +func TestClient_Currencies_Success(t *testing.T) { + tests := []struct { + name string + givenDoer *mockDoer + givenClientOpts []oxr.ClientOption + givenCurrenciesOpts []oxr.CurrenciesOption + expectedURL string + expectedResult oxr.CurrenciesResponse + }{ + { + name: "given successful currencies response, expect payload returned", + givenDoer: &mockDoer{ + GivenResponse: &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(successfulCurrencies())), + }, + }, + givenClientOpts: []oxr.ClientOption{ + oxr.WithAppID("test"), + }, + givenCurrenciesOpts: []oxr.CurrenciesOption{ + oxr.CurrenciesWithInactive(true), + oxr.CurrenciesWithPrettyPrint(true), + }, + expectedURL: "https://openexchangerates.org/api/currencies.json?app_id=test&prettyprint=true&show_alternative=false&show_inactive=true", + expectedResult: oxr.CurrenciesResponse{ + Currencies: map[string]string{ + "EUR": "Euro", + "GBP": "Pound sterling", + "USD": "US Dollar", + }, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c := oxr.New(append(test.givenClientOpts, oxr.WithDoer(test.givenDoer))...) + + actual, err := c.Currencies(context.Background(), test.givenCurrenciesOpts...) + if err != nil { + t.Fatal(err) + } + + if !cmp.Equal(test.givenDoer.SpyURL, test.expectedURL) { + t.Fatal(cmp.Diff(test.givenDoer.SpyURL, test.expectedURL)) + } + + if !cmp.Equal(actual, test.expectedResult) { + t.Fatal(cmp.Diff(actual, test.expectedResult)) + } + }) + } +} + +func TestClient_Currencies_Fail(t *testing.T) { + tests := []struct { + name string + givenDoer *mockDoer + givenClientOpts []oxr.ClientOption + givenCurrenciesOpts []oxr.CurrenciesOption + expectedURL string + expectedError error + }{ + { + name: "given doer error, expect error returned", + givenDoer: &mockDoer{ + GivenResponse: &http.Response{ + Body: io.NopCloser(strings.NewReader("")), + }, + GivenError: http.ErrBodyNotAllowed, + }, + givenClientOpts: []oxr.ClientOption{ + oxr.WithAppID("test"), + }, + givenCurrenciesOpts: []oxr.CurrenciesOption{}, + expectedURL: "https://openexchangerates.org/api/currencies.json?app_id=test&prettyprint=false&show_alternative=false&show_inactive=false", + expectedError: http.ErrBodyNotAllowed, + }, + { + name: "given non 200 response, expect error returned", + givenDoer: &mockDoer{ + GivenResponse: &http.Response{ + Status: http.StatusText(http.StatusForbidden), + StatusCode: http.StatusForbidden, + Body: io.NopCloser(strings.NewReader("")), + }, + }, + givenClientOpts: []oxr.ClientOption{ + oxr.WithAppID("test"), + }, + givenCurrenciesOpts: []oxr.CurrenciesOption{}, + expectedURL: "https://openexchangerates.org/api/currencies.json?app_id=test&prettyprint=false&show_alternative=false&show_inactive=false", + expectedError: oxr.ErrBadResponse, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c := oxr.New(append(test.givenClientOpts, oxr.WithDoer(test.givenDoer))...) + + _, err := c.Currencies(context.Background(), test.givenCurrenciesOpts...) + if err == nil { + t.Fatalf("expected %v, got nil", err) + } + + if !cmp.Equal(test.givenDoer.SpyURL, test.expectedURL) { + t.Fatal(cmp.Diff(test.givenDoer.SpyURL, test.expectedURL)) + } + + if !cmp.Equal(err, test.expectedError, cmpopts.EquateErrors()) { + t.Fatal(cmp.Diff(err, test.expectedError, cmpopts.EquateErrors())) + } + }) + } +} + +func TestClient_Historical_Success(t *testing.T) { + tests := []struct { + name string + givenDoer *mockDoer + givenClientOpts []oxr.ClientOption + givenHistoricalOpts []oxr.HistoricalOption + expectedURL string + expectedResult oxr.HistoricalRatesResponse + }{ + { + name: "given successful historical response, expect payload returned", + givenDoer: &mockDoer{ + GivenResponse: &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(successfulHistorical())), + }, + }, + givenClientOpts: []oxr.ClientOption{ + oxr.WithAppID("test"), + }, + givenHistoricalOpts: []oxr.HistoricalOption{ + oxr.HistoricalForDate(time.Date(2022, 3, 10, 12, 0, 0, 0, time.UTC)), + oxr.HistoricalForBaseCurrency("USD"), + }, + expectedURL: "https://openexchangerates.org/api/historical/2022-03-10.json?app_id=test&base=USD&prettyprint=false&show_alternative=false", + expectedResult: oxr.HistoricalRatesResponse{ + Disclaimer: "Usage subject to terms: https://openexchangerates.org/terms", + License: "https://openexchangerates.org/license", + Timestamp: 1341936000, + Base: "USD", + Rates: map[string]float64{ + "GBP": 0.76, + "EUR": 0.93, + }, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c := oxr.New(append(test.givenClientOpts, oxr.WithDoer(test.givenDoer))...) + + actual, err := c.Historical(context.Background(), test.givenHistoricalOpts...) + if err != nil { + t.Fatal(err) + } + + if !cmp.Equal(test.givenDoer.SpyURL, test.expectedURL) { + t.Fatal(cmp.Diff(test.givenDoer.SpyURL, test.expectedURL)) + } + + if !cmp.Equal(actual, test.expectedResult) { + t.Fatal(cmp.Diff(actual, test.expectedResult)) + } + }) + } +} + +func TestClient_Historical_Fail(t *testing.T) { + tests := []struct { + name string + givenDoer *mockDoer + givenClientOpts []oxr.ClientOption + givenHistoricalOpts []oxr.HistoricalOption + expectedURL string + expectedError error + }{ + { + name: "given doer error, expect error returned", + givenDoer: &mockDoer{ + GivenResponse: &http.Response{ + Body: io.NopCloser(strings.NewReader("")), + }, + GivenError: http.ErrBodyNotAllowed, + }, + givenClientOpts: []oxr.ClientOption{ + oxr.WithAppID("test"), + }, + givenHistoricalOpts: []oxr.HistoricalOption{ + oxr.HistoricalForDate(time.Date(2022, 3, 10, 12, 0, 0, 0, time.UTC)), + oxr.HistoricalForBaseCurrency("USD"), + }, + expectedURL: "https://openexchangerates.org/api/historical/2022-03-10.json?app_id=test&base=USD&prettyprint=false&show_alternative=false", + expectedError: http.ErrBodyNotAllowed, + }, + { + name: "given non 200 response, expect error returned", + givenDoer: &mockDoer{ + GivenResponse: &http.Response{ + Status: http.StatusText(http.StatusForbidden), + StatusCode: http.StatusForbidden, + Body: io.NopCloser(strings.NewReader("")), + }, + }, + givenClientOpts: []oxr.ClientOption{ + oxr.WithAppID("test"), + }, + givenHistoricalOpts: []oxr.HistoricalOption{ + oxr.HistoricalForDate(time.Date(2022, 3, 10, 12, 0, 0, 0, time.UTC)), + oxr.HistoricalForBaseCurrency("USD"), + }, + expectedURL: "https://openexchangerates.org/api/historical/2022-03-10.json?app_id=test&base=USD&prettyprint=false&show_alternative=false", + expectedError: oxr.ErrBadResponse, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c := oxr.New(append(test.givenClientOpts, oxr.WithDoer(test.givenDoer))...) + + _, err := c.Historical(context.Background(), test.givenHistoricalOpts...) + if err == nil { + t.Fatalf("expected %v, got nil", err) + } + + if !cmp.Equal(test.givenDoer.SpyURL, test.expectedURL) { + t.Fatal(cmp.Diff(test.givenDoer.SpyURL, test.expectedURL)) + } + + if !cmp.Equal(err, test.expectedError, cmpopts.EquateErrors()) { + t.Fatal(cmp.Diff(err, test.expectedError, cmpopts.EquateErrors())) + } + }) + } +} + +func TestClient_Latest_Success(t *testing.T) { + tests := []struct { + name string + givenDoer *mockDoer + givenClientOpts []oxr.ClientOption + givenLatestOpts []oxr.LatestOption + expectedURL string + expectedResult oxr.LatestRatesResponse + }{ + { + name: "given successful latest response, expect payload returned", + givenDoer: &mockDoer{ + GivenResponse: &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(successfulLatest())), + }, + }, + givenClientOpts: []oxr.ClientOption{ + oxr.WithAppID("test"), + }, + givenLatestOpts: []oxr.LatestOption{ + oxr.LatestForBaseCurrency("USD"), + }, + expectedURL: "https://openexchangerates.org/api/latest.json?app_id=test&base=USD&prettyprint=false&show_alternative=false", + expectedResult: oxr.LatestRatesResponse{ + Disclaimer: "Usage subject to terms: https://openexchangerates.org/terms", + License: "https://openexchangerates.org/license", + Timestamp: 1647453600, + Base: "USD", + Rates: map[string]float64{ + "GBP": 0.764018, + "KRW": 1225.826828, + "USD": 1, + }, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c := oxr.New(append(test.givenClientOpts, oxr.WithDoer(test.givenDoer))...) + + actual, err := c.Latest(context.Background(), test.givenLatestOpts...) + if err != nil { + t.Fatal(err) + } + + if !cmp.Equal(test.givenDoer.SpyURL, test.expectedURL) { + t.Fatal(cmp.Diff(test.givenDoer.SpyURL, test.expectedURL)) + } + + if !cmp.Equal(actual, test.expectedResult) { + t.Fatal(cmp.Diff(actual, test.expectedResult)) + } + }) + } +} + +func TestClient_Latest_Fail(t *testing.T) { + tests := []struct { + name string + givenDoer *mockDoer + givenClientOpts []oxr.ClientOption + givenLatestOpts []oxr.LatestOption + expectedURL string + expectedError error + }{ + { + name: "given doer error, expect error returned", + givenDoer: &mockDoer{ + GivenResponse: &http.Response{ + Body: io.NopCloser(strings.NewReader("")), + }, + GivenError: http.ErrBodyNotAllowed, + }, + givenClientOpts: []oxr.ClientOption{ + oxr.WithAppID("test"), + }, + givenLatestOpts: []oxr.LatestOption{ + oxr.LatestForBaseCurrency("USD"), + }, + expectedURL: "https://openexchangerates.org/api/latest.json?app_id=test&base=USD&prettyprint=false&show_alternative=false", + expectedError: http.ErrBodyNotAllowed, + }, + { + name: "given non 200 response, expect error returned", + givenDoer: &mockDoer{ + GivenResponse: &http.Response{ + Status: http.StatusText(http.StatusForbidden), + StatusCode: http.StatusForbidden, + Body: io.NopCloser(strings.NewReader("")), + }, + }, + givenClientOpts: []oxr.ClientOption{ + oxr.WithAppID("test"), + }, + givenLatestOpts: []oxr.LatestOption{ + oxr.LatestForBaseCurrency("USD"), + }, + expectedURL: "https://openexchangerates.org/api/latest.json?app_id=test&base=USD&prettyprint=false&show_alternative=false", + expectedError: oxr.ErrBadResponse, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c := oxr.New(append(test.givenClientOpts, oxr.WithDoer(test.givenDoer))...) + + _, err := c.Latest(context.Background(), test.givenLatestOpts...) + if err == nil { + t.Fatalf("expected %v, got nil", err) + } + + if !cmp.Equal(test.givenDoer.SpyURL, test.expectedURL) { + t.Fatal(cmp.Diff(test.givenDoer.SpyURL, test.expectedURL)) + } + + if !cmp.Equal(err, test.expectedError, cmpopts.EquateErrors()) { + t.Fatal(cmp.Diff(err, test.expectedError, cmpopts.EquateErrors())) + } + }) + } +} + +func TestClient_OpenHighLowClose_Success(t *testing.T) { + tests := []struct { + name string + givenDoer *mockDoer + givenClientOpts []oxr.ClientOption + givenOHLCOpts []oxr.OHLCOption + expectedURL string + expectedResult oxr.OHLCResponse + }{ + { + name: "given successful ohlc response, expect payload returned", + givenDoer: &mockDoer{ + GivenResponse: &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(successfulOHLC())), + }, + }, + givenClientOpts: []oxr.ClientOption{ + oxr.WithAppID("test"), + }, + givenOHLCOpts: []oxr.OHLCOption{ + oxr.OHLCForBaseCurrency("USD"), + oxr.OHLCForPeriod(oxr.ThirtyMinute), + oxr.OHLCForDestinationCurrencies([]string{"GBP", "EUR"}), + oxr.OHLCForStartTime(time.Date(2022, 3, 15, 13, 0, 0, 0, time.UTC)), + }, + expectedURL: "https://openexchangerates.org/api/ohlc.json?app_id=test&base=USD&period=30m&prettyprint=false&start_date=2022-03-15T13%3A00%3A00Z&symbols=GBP%2CEUR", + expectedResult: oxr.OHLCResponse{ + Disclaimer: "Usage subject to terms: https://openexchangerates.org/terms", + License: "https://openexchangerates.org/license", + Base: "USD", + StartTime: time.Date(2022, 3, 15, 13, 0, 0, 0, time.UTC), + EndTime: time.Date(2022, 3, 15, 13, 30, 0, 0, time.UTC), + Rates: map[string]oxr.OHLCRate{ + "EUR": { + Open: 0.872674, + High: 0.872674, + Low: 0.87203, + Close: 0.872251, + Average: 0.872253, + }, + "GBP": { + Open: 0.765284, + High: 0.7657, + Low: 0.7652, + Close: 0.765541, + Average: 0.765503, + }, + }, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c := oxr.New(append(test.givenClientOpts, oxr.WithDoer(test.givenDoer))...) + + actual, err := c.OpenHighLowClose(context.Background(), test.givenOHLCOpts...) + if err != nil { + t.Fatal(err) + } + + if !cmp.Equal(test.givenDoer.SpyURL, test.expectedURL) { + t.Fatal(cmp.Diff(test.givenDoer.SpyURL, test.expectedURL)) + } + + if !cmp.Equal(actual, test.expectedResult) { + t.Fatal(cmp.Diff(actual, test.expectedResult)) + } + }) + } +} + +func TestClient_OpenHighLowClose_Fail(t *testing.T) { + tests := []struct { + name string + givenDoer *mockDoer + givenClientOpts []oxr.ClientOption + givenOHLCOpts []oxr.OHLCOption + expectedURL string + expectedError error + }{ + { + name: "given doer error, expect error returned", + givenDoer: &mockDoer{ + GivenResponse: &http.Response{ + Body: io.NopCloser(strings.NewReader("")), + }, + GivenError: http.ErrBodyNotAllowed, + }, + givenClientOpts: []oxr.ClientOption{ + oxr.WithAppID("test"), + }, + givenOHLCOpts: []oxr.OHLCOption{}, + expectedURL: "https://openexchangerates.org/api/ohlc.json?app_id=test&period=&prettyprint=false&start_date=0001-01-01T00%3A00%3A00Z", + expectedError: http.ErrBodyNotAllowed, + }, + { + name: "given non 200 response, expect error returned", + givenDoer: &mockDoer{ + GivenResponse: &http.Response{ + Status: http.StatusText(http.StatusForbidden), + StatusCode: http.StatusForbidden, + Body: io.NopCloser(strings.NewReader("")), + }, + }, + givenClientOpts: []oxr.ClientOption{ + oxr.WithAppID("test"), + }, + givenOHLCOpts: []oxr.OHLCOption{}, + expectedURL: "https://openexchangerates.org/api/ohlc.json?app_id=test&period=&prettyprint=false&start_date=0001-01-01T00%3A00%3A00Z", + expectedError: oxr.ErrBadResponse, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c := oxr.New(append(test.givenClientOpts, oxr.WithDoer(test.givenDoer))...) + + _, err := c.OpenHighLowClose(context.Background(), test.givenOHLCOpts...) + if err == nil { + t.Fatalf("expected %v, got nil", err) + } + + if !cmp.Equal(test.givenDoer.SpyURL, test.expectedURL) { + t.Fatal(cmp.Diff(test.givenDoer.SpyURL, test.expectedURL)) + } + + if !cmp.Equal(err, test.expectedError, cmpopts.EquateErrors()) { + t.Fatal(cmp.Diff(err, test.expectedError, cmpopts.EquateErrors())) + } + }) + } +} + +func TestClient_TimeSeries_Success(t *testing.T) { + tests := []struct { + name string + givenDoer *mockDoer + givenClientOpts []oxr.ClientOption + givenTimeSeriesOpts []oxr.TimeSeriesOption + expectedURL string + expectedResult oxr.TimeSeriesResponse + }{ + { + name: "given successful time series response, expect payload returned", + givenDoer: &mockDoer{ + GivenResponse: &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(successfulTimeSeries())), + }, + }, + givenClientOpts: []oxr.ClientOption{ + oxr.WithAppID("test"), + }, + givenTimeSeriesOpts: []oxr.TimeSeriesOption{ + oxr.TimeSeriesForStartDate(time.Date(2013, 1, 1, 13, 0, 0, 0, time.UTC)), + oxr.TimeSeriesForEndDate(time.Date(2013, 1, 31, 13, 0, 0, 0, time.UTC)), + oxr.TimeSeriesForBaseCurrency("AUD"), + oxr.TimeSeriesForDestinationCurrencies([]string{"BTC", "EUR", "HKD"}), + oxr.TimeSeriesWithPrettyPrint(true), + }, + expectedURL: "https://openexchangerates.org/api/time-series.json?app_id=test&base=AUD&end=2013-01-31&prettyprint=true&show_alternative=false&start=2013-01-01&symbols=BTC%2CEUR%2CHKD", + expectedResult: oxr.TimeSeriesResponse{ + Disclaimer: "Usage subject to terms: https://openexchangerates.org/terms/", + License: "https://openexchangerates.org/license/", + Base: "AUD", + StartDate: "2013-01-01", + EndDate: "2013-01-31", + Rates: map[string]map[string]float64{ + "2013-01-01": { + "BTC": 0.0778595876, + "EUR": 0.785518, + "HKD": 8.04136, + }, + "2013-01-02": { + "BTC": 0.0789400739, + "EUR": 0.795034, + "HKD": 8.138096, + }, + "2013-01-03": { + "BTC": 0.0785299961, + "EUR": 0.80092, + "HKD": 8.116954, + }, + }, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c := oxr.New(append(test.givenClientOpts, oxr.WithDoer(test.givenDoer))...) + + actual, err := c.TimeSeries(context.Background(), test.givenTimeSeriesOpts...) + if err != nil { + t.Fatal(err) + } + + if !cmp.Equal(test.givenDoer.SpyURL, test.expectedURL) { + t.Fatal(cmp.Diff(test.givenDoer.SpyURL, test.expectedURL)) + } + + if !cmp.Equal(actual, test.expectedResult) { + t.Fatal(cmp.Diff(actual, test.expectedResult)) + } + }) + } +} + +func TestClient_TimeSeries_Fail(t *testing.T) { + tests := []struct { + name string + givenDoer *mockDoer + givenClientOpts []oxr.ClientOption + givenTimeSeriesOpts []oxr.TimeSeriesOption + expectedURL string + expectedError error + }{ + { + name: "given doer error, expect error returned", + givenDoer: &mockDoer{ + GivenResponse: &http.Response{ + Body: io.NopCloser(strings.NewReader("")), + }, + GivenError: http.ErrBodyNotAllowed, + }, + givenClientOpts: []oxr.ClientOption{ + oxr.WithAppID("test"), + }, + givenTimeSeriesOpts: []oxr.TimeSeriesOption{}, + expectedURL: "https://openexchangerates.org/api/time-series.json?app_id=test&end=0001-01-01&prettyprint=false&show_alternative=false&start=0001-01-01", + expectedError: http.ErrBodyNotAllowed, + }, + { + name: "given non 200 response, expect error returned", + givenDoer: &mockDoer{ + GivenResponse: &http.Response{ + Status: http.StatusText(http.StatusForbidden), + StatusCode: http.StatusForbidden, + Body: io.NopCloser(strings.NewReader("")), + }, + }, + givenClientOpts: []oxr.ClientOption{ + oxr.WithAppID("test"), + }, + givenTimeSeriesOpts: []oxr.TimeSeriesOption{}, + expectedURL: "https://openexchangerates.org/api/time-series.json?app_id=test&end=0001-01-01&prettyprint=false&show_alternative=false&start=0001-01-01", + expectedError: oxr.ErrBadResponse, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c := oxr.New(append(test.givenClientOpts, oxr.WithDoer(test.givenDoer))...) + + _, err := c.TimeSeries(context.Background(), test.givenTimeSeriesOpts...) + if err == nil { + t.Fatalf("expected %v, got nil", err) + } + + if !cmp.Equal(test.givenDoer.SpyURL, test.expectedURL) { + t.Fatal(cmp.Diff(test.givenDoer.SpyURL, test.expectedURL)) + } + + if !cmp.Equal(err, test.expectedError, cmpopts.EquateErrors()) { + t.Fatal(cmp.Diff(err, test.expectedError, cmpopts.EquateErrors())) + } + }) + } +} + +func TestClient_Usage_Success(t *testing.T) { + tests := []struct { + name string + givenDoer *mockDoer + givenClientOpts []oxr.ClientOption + givenUsageOpts []oxr.UsageOption + expectedURL string + expectedResult oxr.UsageResponse + }{ + { + name: "given successful usage response, expect payload returned", + givenDoer: &mockDoer{ + GivenResponse: &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(successfulUsage())), + }, + }, + givenClientOpts: []oxr.ClientOption{ + oxr.WithAppID("test"), + }, + givenUsageOpts: []oxr.UsageOption{ + oxr.UsageWithPrettyPrint(true), + }, + expectedURL: "https://openexchangerates.org/api/usage.json?app_id=test&prettyprint=true", + expectedResult: oxr.UsageResponse{ + Status: 200, + Data: oxr.UsageData{ + AppID: "YOUR_APP_ID", + Status: "active", + Plan: oxr.UsageDataPlan{ + Name: "Enterprise", + Quota: "100,000 requests/month", + UpdateFrequency: "30-minute", + Features: oxr.UsageDataPlanFeatures{ + Base: true, + Symbols: true, + Experimental: true, + TimeSeries: true, + Convert: false, + }, + }, + Usage: oxr.DataUsage{ + Requests: 54524, + RequestsQuota: 100000, + RequestsRemaining: 45476, + DaysElapsed: 16, + DaysRemaining: 14, + DailyAverage: 2842, + }, + }, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c := oxr.New(append(test.givenClientOpts, oxr.WithDoer(test.givenDoer))...) + + actual, err := c.Usage(context.Background(), test.givenUsageOpts...) + if err != nil { + t.Fatal(err) + } + + if !cmp.Equal(test.givenDoer.SpyURL, test.expectedURL) { + t.Fatal(cmp.Diff(test.givenDoer.SpyURL, test.expectedURL)) + } + + if !cmp.Equal(actual, test.expectedResult) { + t.Fatal(cmp.Diff(actual, test.expectedResult)) + } + }) + } +} + +func TestClient_Usage_Fail(t *testing.T) { + tests := []struct { + name string + givenDoer *mockDoer + givenClientOpts []oxr.ClientOption + givenUsageOpts []oxr.UsageOption + expectedURL string + expectedError error + }{ + { + name: "given doer error, expect error returned", + givenDoer: &mockDoer{ + GivenResponse: &http.Response{ + Body: io.NopCloser(strings.NewReader("")), + }, + GivenError: http.ErrBodyNotAllowed, + }, + givenClientOpts: []oxr.ClientOption{ + oxr.WithAppID("test"), + }, + givenUsageOpts: []oxr.UsageOption{}, + expectedURL: "https://openexchangerates.org/api/usage.json?app_id=test&prettyprint=false", + expectedError: http.ErrBodyNotAllowed, + }, + { + name: "given non 200 response, expect error returned", + givenDoer: &mockDoer{ + GivenResponse: &http.Response{ + Status: http.StatusText(http.StatusForbidden), + StatusCode: http.StatusForbidden, + Body: io.NopCloser(strings.NewReader("")), + }, + }, + givenClientOpts: []oxr.ClientOption{ + oxr.WithAppID("test"), + }, + givenUsageOpts: []oxr.UsageOption{}, + expectedURL: "https://openexchangerates.org/api/usage.json?app_id=test&prettyprint=false", + expectedError: oxr.ErrBadResponse, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c := oxr.New(append(test.givenClientOpts, oxr.WithDoer(test.givenDoer))...) + + _, err := c.Usage(context.Background(), test.givenUsageOpts...) + if err == nil { + t.Fatalf("expected %v, got nil", err) + } + + if !cmp.Equal(test.givenDoer.SpyURL, test.expectedURL) { + t.Fatal(cmp.Diff(test.givenDoer.SpyURL, test.expectedURL)) + } + + if !cmp.Equal(err, test.expectedError, cmpopts.EquateErrors()) { + t.Fatal(cmp.Diff(err, test.expectedError, cmpopts.EquateErrors())) + } + }) + } +} + +type mockDoer struct { + GivenResponse *http.Response + GivenError error + SpyURL string +} + +func (m *mockDoer) Do(r *http.Request) (*http.Response, error) { + m.SpyURL = r.URL.String() + + return m.GivenResponse, m.GivenError +} + +func successfulConversion() string { + return `{ + "disclaimer": "https://openexchangerates.org/terms/", + "license": "https://openexchangerates.org/license/", + "request": { + "query": "/convert/100.12/GBP/USD", + "amount": 100.12, + "from": "GBP", + "to": "USD" + }, + "meta": { + "timestamp": 1449885661, + "rate": 0.76 + }, + "response": 76.0912 +}` +} + +func successfulCurrencies() string { + return `{ + "EUR": "Euro", + "GBP": "Pound sterling", + "USD": "US Dollar" +}` +} + +func successfulHistorical() string { + return `{ + "disclaimer": "Usage subject to terms: https://openexchangerates.org/terms", + "license": "https://openexchangerates.org/license", + "timestamp": 1341936000, + "base": "USD", + "rates": { + "GBP": 0.76, + "EUR": 0.93 + } +}` +} + +func successfulLatest() string { + return `{ + "disclaimer": "Usage subject to terms: https://openexchangerates.org/terms", + "license": "https://openexchangerates.org/license", + "timestamp": 1647453600, + "base": "USD", + "rates": { + "GBP": 0.764018, + "KRW": 1225.826828, + "USD": 1 + } +}` +} + +func successfulOHLC() string { + return `{ + "disclaimer": "Usage subject to terms: https://openexchangerates.org/terms", + "license": "https://openexchangerates.org/license", + "start_time": "2022-03-15T13:00:00Z", + "end_time": "2022-03-15T13:30:00Z", + "base": "USD", + "rates": { + "EUR": { + "open": 0.872674, + "high": 0.872674, + "low": 0.87203, + "close": 0.872251, + "average": 0.872253 + }, + "GBP": { + "open": 0.765284, + "high": 0.7657, + "low": 0.7652, + "close": 0.765541, + "average": 0.765503 + } + } +}` +} + +func successfulTimeSeries() string { + return `{ + "disclaimer": "Usage subject to terms: https://openexchangerates.org/terms/", + "license": "https://openexchangerates.org/license/", + "start_date": "2013-01-01", + "end_date": "2013-01-31", + "base": "AUD", + "rates": { + "2013-01-01": { + "BTC": 0.0778595876, + "EUR": 0.785518, + "HKD": 8.04136 + }, + "2013-01-02": { + "BTC": 0.0789400739, + "EUR": 0.795034, + "HKD": 8.138096 + }, + "2013-01-03": { + "BTC": 0.0785299961, + "EUR": 0.80092, + "HKD": 8.116954 + } + } +}` +} + +func successfulUsage() string { + return `{ + "status": 200, + "data": { + "app_id": "YOUR_APP_ID", + "status": "active", + "plan": { + "name": "Enterprise", + "quota": "100,000 requests/month", + "update_frequency": "30-minute", + "features": { + "base": true, + "symbols": true, + "experimental": true, + "time-series": true, + "convert": false + } + }, + "usage": { + "requests": 54524, + "requests_quota": 100000, + "requests_remaining": 45476, + "days_elapsed": 16, + "days_remaining": 14, + "daily_average": 2842 + } + } +}` +} diff --git a/convert_opts.go b/convert_opts.go new file mode 100644 index 0000000..3342765 --- /dev/null +++ b/convert_opts.go @@ -0,0 +1,39 @@ +package oxr + +type convertParams struct { + value float64 + baseCurrency string + destinationCurrency string + prettyPrint bool +} + +// ConvertOption allows the client to specify values for a conversion request. +type ConvertOption func(*convertParams) + +// ConvertForBaseCurrency sets the base currency for a conversion. +func ConvertForBaseCurrency(currency string) ConvertOption { + return func(p *convertParams) { + p.baseCurrency = currency + } +} + +// ConvertForDestinationCurrency sets the destination currency for a conversion. +func ConvertForDestinationCurrency(currency string) ConvertOption { + return func(p *convertParams) { + p.destinationCurrency = currency + } +} + +// ConvertWithValue sets the value to be converted. +func ConvertWithValue(value float64) ConvertOption { + return func(p *convertParams) { + p.value = value + } +} + +// ConvertWithPrettyPrint sets whether to minify the response. +func ConvertWithPrettyPrint(active bool) ConvertOption { + return func(p *convertParams) { + p.prettyPrint = active + } +} diff --git a/currencies_opts.go b/currencies_opts.go new file mode 100644 index 0000000..8bc07ac --- /dev/null +++ b/currencies_opts.go @@ -0,0 +1,31 @@ +package oxr + +type currenciesParams struct { + showAlternative bool + showInactive bool + prettyPrint bool +} + +// CurrenciesOption allows the client to specify values for a currencies request. +type CurrenciesOption func(params *currenciesParams) + +// CurrenciesWithAlternatives includes alternative currencies. +func CurrenciesWithAlternatives(active bool) CurrenciesOption { + return func(p *currenciesParams) { + p.showAlternative = active + } +} + +// CurrenciesWithInactive includes historical/inactive currencies. +func CurrenciesWithInactive(active bool) CurrenciesOption { + return func(p *currenciesParams) { + p.showInactive = active + } +} + +// CurrenciesWithPrettyPrint sets whether to minify the response. +func CurrenciesWithPrettyPrint(active bool) CurrenciesOption { + return func(p *currenciesParams) { + p.prettyPrint = active + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3fd142c --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module github.com/jamieaitken/oxr + +go 1.17 + +require github.com/google/go-cmp v0.5.7 + +require golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a6ca3a4 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/historical_opts.go b/historical_opts.go new file mode 100644 index 0000000..1215b9d --- /dev/null +++ b/historical_opts.go @@ -0,0 +1,52 @@ +package oxr + +import ( + "strings" + "time" +) + +type historicalParams struct { + date time.Time + baseCurrency string + destinationCurrencies string + showAlternative bool + prettyPrint bool +} + +// HistoricalOption allows the client to specify values for a historical request. +type HistoricalOption func(*historicalParams) + +// HistoricalForBaseCurrency sets the base currency for the historical request. +func HistoricalForBaseCurrency(currency string) HistoricalOption { + return func(p *historicalParams) { + p.baseCurrency = currency + } +} + +// HistoricalForDestinationCurrencies sets the destination currency for the historical request. +func HistoricalForDestinationCurrencies(currencies []string) HistoricalOption { + return func(p *historicalParams) { + p.destinationCurrencies = strings.Join(currencies, ",") + } +} + +// HistoricalWithAlternatives sets whether to include alternative currencies. +func HistoricalWithAlternatives(active bool) HistoricalOption { + return func(p *historicalParams) { + p.showAlternative = active + } +} + +// HistoricalForDate sets the date of the rate to be requested. +func HistoricalForDate(date time.Time) HistoricalOption { + return func(p *historicalParams) { + p.date = date + } +} + +// HistoricalWithPrettyPrint sets whether to minify the response. +func HistoricalWithPrettyPrint(active bool) HistoricalOption { + return func(p *historicalParams) { + p.prettyPrint = active + } +} diff --git a/latest_opts.go b/latest_opts.go new file mode 100644 index 0000000..bbc9dd4 --- /dev/null +++ b/latest_opts.go @@ -0,0 +1,43 @@ +package oxr + +import ( + "strings" +) + +type latestParams struct { + baseCurrency string + destinationCurrencies string + showAlternative bool + prettyPrint bool +} + +// LatestOption allows the client to specify values for a latest request. +type LatestOption func(params *latestParams) + +// LatestForBaseCurrency sets the base currency. +func LatestForBaseCurrency(currency string) LatestOption { + return func(p *latestParams) { + p.baseCurrency = currency + } +} + +// LatestForDestinationCurrencies sets the destination currencies to be included in the response. +func LatestForDestinationCurrencies(currencies []string) LatestOption { + return func(p *latestParams) { + p.destinationCurrencies = strings.Join(currencies, ",") + } +} + +// LatestWithAlternatives sets whether to include alternative currencies. +func LatestWithAlternatives(active bool) LatestOption { + return func(p *latestParams) { + p.showAlternative = active + } +} + +// LatestWithPrettyPrint sets whether to minify the response. +func LatestWithPrettyPrint(active bool) LatestOption { + return func(p *latestParams) { + p.prettyPrint = active + } +} diff --git a/models.go b/models.go new file mode 100644 index 0000000..990a3ce --- /dev/null +++ b/models.go @@ -0,0 +1,112 @@ +package oxr + +import "time" + +// LatestRatesResponse is the response of a Latest request. +type LatestRatesResponse struct { + Disclaimer string `json:"disclaimer"` + License string `json:"license"` + Timestamp int64 `json:"timestamp"` + Base string `json:"base"` + Rates map[string]float64 `json:"rates"` +} + +// ConversionResponse is the response of a Conversion request. +type ConversionResponse struct { + Disclaimer string `json:"disclaimer"` + License string `json:"license"` + Request ConversionRequest `json:"request"` + Meta ConversionMeta `json:"meta"` + Response float64 `json:"response"` +} + +type ConversionRequest struct { + Query string `json:"query"` + Amount float64 `json:"amount"` + From string `json:"from"` + To string `json:"to"` +} + +type ConversionMeta struct { + Timestamp int64 `json:"timestamp"` + Rate float64 `json:"rate"` +} + +// CurrenciesResponse is the response of a Currencies request. +type CurrenciesResponse struct { + Currencies map[string]string +} + +// HistoricalRatesResponse is the response of a Historical request. +type HistoricalRatesResponse struct { + Disclaimer string `json:"disclaimer"` + License string `json:"license"` + Timestamp int64 `json:"timestamp"` + Base string `json:"base"` + Rates map[string]float64 `json:"rates"` +} + +// OHLCResponse is the response of a OHLC request. +type OHLCResponse struct { + Disclaimer string `json:"disclaimer"` + License string `json:"license"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + Base string `json:"base"` + Rates map[string]OHLCRate `json:"rates"` +} + +type OHLCRate struct { + Open float64 `json:"open"` + High float64 `json:"high"` + Low float64 `json:"low"` + Close float64 `json:"close"` + Average float64 `json:"average"` +} + +// TimeSeriesResponse is the response of a TimeSeries request. +type TimeSeriesResponse struct { + Disclaimer string `json:"disclaimer"` + License string `json:"license"` + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` + Base string `json:"base"` + Rates map[string]map[string]float64 `json:"rates"` +} + +// UsageResponse is the response of a Usage request. +type UsageResponse struct { + Status int `json:"status"` + Data UsageData `json:"data"` +} + +type UsageData struct { + AppID string `json:"app_id"` + Status string `json:"status"` + Plan UsageDataPlan `json:"plan"` + Usage DataUsage `json:"usage"` +} + +type DataUsage struct { + Requests int `json:"requests"` + RequestsQuota int `json:"requests_quota"` + RequestsRemaining int `json:"requests_remaining"` + DaysElapsed int `json:"days_elapsed"` + DaysRemaining int `json:"days_remaining"` + DailyAverage int `json:"daily_average"` +} + +type UsageDataPlan struct { + Name string `json:"name"` + Quota string `json:"quota"` + UpdateFrequency string `json:"update_frequency"` + Features UsageDataPlanFeatures `json:"features"` +} + +type UsageDataPlanFeatures struct { + Base bool `json:"base"` + Symbols bool `json:"symbols"` + Experimental bool `json:"experimental"` + TimeSeries bool `json:"time-series"` + Convert bool `json:"convert"` +} diff --git a/ohlc_opts.go b/ohlc_opts.go new file mode 100644 index 0000000..1396379 --- /dev/null +++ b/ohlc_opts.go @@ -0,0 +1,72 @@ +package oxr + +import ( + "strings" + "time" +) + +// Available periods. +const ( + OneMinute period = "1m" + FiveMinute period = "5m" + FifteenMinute period = "15m" + ThirtyMinute period = "30m" + OneHour period = "1h" + TwelveHour period = "12h" + OneDay period = "1d" + OneWeek period = "1w" + OneMonth period = "1mo" +) + +type ohlcParams struct { + startTime time.Time + period period + baseCurrency string + destinationCurrencies string + prettyPrint bool +} + +type period string + +// String implements a fmt.Stringer for period. +func (p period) String() string { + return string(p) +} + +// OHLCOption allows the client to specify values for a OHLC request. +type OHLCOption func(params *ohlcParams) + +// OHLCWithPrettyPrint sets whether to minify the response. +func OHLCWithPrettyPrint(active bool) OHLCOption { + return func(p *ohlcParams) { + p.prettyPrint = active + } +} + +// OHLCForStartTime sets the start time for the given period. +func OHLCForStartTime(startTime time.Time) OHLCOption { + return func(p *ohlcParams) { + p.startTime = startTime + } +} + +// OHLCForPeriod sets the length of the period. +func OHLCForPeriod(period period) OHLCOption { + return func(p *ohlcParams) { + p.period = period + } +} + +// OHLCForBaseCurrency sets the base currency. +func OHLCForBaseCurrency(currency string) OHLCOption { + return func(p *ohlcParams) { + p.baseCurrency = currency + } +} + +// OHLCForDestinationCurrencies sets the destination currencies to be included in the response. +func OHLCForDestinationCurrencies(destinationCurrencies []string) OHLCOption { + return func(p *ohlcParams) { + p.destinationCurrencies = strings.Join(destinationCurrencies, ",") + } +} diff --git a/time_series_opts.go b/time_series_opts.go new file mode 100644 index 0000000..3f89ebe --- /dev/null +++ b/time_series_opts.go @@ -0,0 +1,60 @@ +package oxr + +import ( + "strings" + "time" +) + +type timeSeriesParams struct { + startDate time.Time + endDate time.Time + baseCurrency string + destinationCurrencies string + showAlternative bool + prettyPrint bool +} + +// TimeSeriesOption allows the client to specify values for a TimeSeries request. +type TimeSeriesOption func(params *timeSeriesParams) + +// TimeSeriesForBaseCurrency sets the base currency to be used. +func TimeSeriesForBaseCurrency(currency string) TimeSeriesOption { + return func(p *timeSeriesParams) { + p.baseCurrency = currency + } +} + +// TimeSeriesForDestinationCurrencies sets the destination currencies to be included in the response. +func TimeSeriesForDestinationCurrencies(currencies []string) TimeSeriesOption { + return func(p *timeSeriesParams) { + p.destinationCurrencies = strings.Join(currencies, ",") + } +} + +// TimeSeriesWithAlternatives sets whether to include alternative currencies in the response. +func TimeSeriesWithAlternatives(active bool) TimeSeriesOption { + return func(p *timeSeriesParams) { + p.showAlternative = active + } +} + +// TimeSeriesForStartDate sets the start date of the period. +func TimeSeriesForStartDate(start time.Time) TimeSeriesOption { + return func(p *timeSeriesParams) { + p.startDate = start + } +} + +// TimeSeriesForEndDate sets the end date of the period. +func TimeSeriesForEndDate(end time.Time) TimeSeriesOption { + return func(p *timeSeriesParams) { + p.endDate = end + } +} + +// TimeSeriesWithPrettyPrint sets whether to minify the response. +func TimeSeriesWithPrettyPrint(active bool) TimeSeriesOption { + return func(p *timeSeriesParams) { + p.prettyPrint = active + } +} diff --git a/usage_opts.go b/usage_opts.go new file mode 100644 index 0000000..066541d --- /dev/null +++ b/usage_opts.go @@ -0,0 +1,15 @@ +package oxr + +type usageParams struct { + prettyPrint bool +} + +// UsageOption allows the client to specify values for a usage request. +type UsageOption func(params *usageParams) + +// UsageWithPrettyPrint sets whether to minify the response. +func UsageWithPrettyPrint(active bool) UsageOption { + return func(p *usageParams) { + p.prettyPrint = active + } +}