From 9a9a6b338bf09717677b442434d55cd35933669a Mon Sep 17 00:00:00 2001 From: Savely Krendelhoff Date: Sat, 6 Sep 2025 15:58:46 +0700 Subject: [PATCH] Implement RateProvider adapter --- go.mod | 14 ++- go.sum | 17 +++ infrastructure/coinmarketcap/client.go | 138 +++++++++++++++++++++++++ infrastructure/coinmarketcap/models.go | 34 ++++++ 4 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 infrastructure/coinmarketcap/client.go create mode 100644 infrastructure/coinmarketcap/models.go diff --git a/go.mod b/go.mod index e05b54f..830fed4 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,16 @@ module converter go 1.24.3 -require github.com/shopspring/decimal v1.4.0 +require ( + github.com/go-resty/resty/v2 v2.16.5 + github.com/shopspring/decimal v1.4.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + golang.org/x/net v0.33.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum index 2ce1d39..5bb5eee 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,19 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM= +github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= +golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/infrastructure/coinmarketcap/client.go b/infrastructure/coinmarketcap/client.go new file mode 100644 index 0000000..e6d83f3 --- /dev/null +++ b/infrastructure/coinmarketcap/client.go @@ -0,0 +1,138 @@ +package coinmarketcap + +import ( + "context" + "errors" + "fmt" + + "github.com/go-resty/resty/v2" +) + +// CoinMarketCap specific errors +var ( + ErrUnknownSymbol = errors.New("unknown symbol") + ErrInvalidConvert = errors.New("invalid convert currency") + ErrAPIError = errors.New("coinmarketcap api error") + ErrInvalidResponse = errors.New("invalid response format") +) + +const ( + baseURL = "https://pro-api.coinmarketcap.com" + quotesPath = "/v1/cryptocurrency/quotes/latest" +) + +// Client implements the RateProvider interface for CoinMarketCap API +type Client struct { + httpClient *resty.Client + apiKey string +} + +// NewClient creates a new CoinMarketCap API client +func NewClient(apiKey string) *Client { + client := resty.New(). + SetBaseURL(baseURL). + SetHeader("Accept", "application/json"). + SetHeader("X-CMC_PRO_API_KEY", apiKey) + + return &Client{ + httpClient: client, + apiKey: apiKey, + } +} + +// GetRate fetches exchange rate from CoinMarketCap API +func (c *Client) GetRate(ctx context.Context, fromCode, toCode string) (RateDTO, error) { + // Try direct conversion first + rateDTO, err := c.tryGetRate(ctx, fromCode, toCode) + if err == nil { + return rateDTO, nil + } + + // If empty result but no other error, try reverse + if err == ErrUnknownSymbol { + reverseDTO, reverseErr := c.tryGetRate(ctx, toCode, fromCode) + if reverseErr == nil { + return reverseDTO, nil + } + + // If reverse request failed with invalid convert, that confirms the original + // "from" currency is indeed unknown (it's now the "convert" parameter) + if reverseErr == ErrInvalidConvert { + return RateDTO{}, err // Return original unknown symbol error + } + + // Any other error from reverse request (network, auth, etc) should be surfaced + return RateDTO{}, reverseErr + } + + // Return original error (either non-unknown-symbol error or first currency not in crypto database) + return RateDTO{}, err +} + +// tryGetRate attempts to get rate in specified direction +func (c *Client) tryGetRate(ctx context.Context, fromCode, toCode string) (RateDTO, error) { + var response APIResponse + + // Make API request + resp, err := c.httpClient.R(). + SetContext(ctx). + SetQueryParam("symbol", fromCode). + SetQueryParam("convert", toCode). + SetResult(&response). + Get(quotesPath) + + if err != nil { + return RateDTO{}, fmt.Errorf("%w: %v", ErrAPIError, err) + } + + // Check HTTP status first + if resp.StatusCode() != 200 { + switch resp.StatusCode() { + case 400: + return RateDTO{}, fmt.Errorf("%w: %s", ErrInvalidConvert, toCode) + case 401: + return RateDTO{}, fmt.Errorf("%w: unauthorized - bad or missing API key", ErrAPIError) + default: + return RateDTO{}, fmt.Errorf("%w: HTTP %d", ErrAPIError, resp.StatusCode()) + } + } + + // Check if data is empty - invalid currency + if len(response.Data) == 0 { + return RateDTO{}, fmt.Errorf("%w: %s", ErrUnknownSymbol, fromCode) + } + + // Extract rate from response + rateDTO, err := c.extractRateDTO(response, fromCode, toCode) + if err != nil { + return RateDTO{}, err + } + + return rateDTO, nil +} + + +// extractRateDTO converts API response to RateDTO +func (c *Client) extractRateDTO(response APIResponse, fromCode, toCode string) (RateDTO, error) { + // Get crypto data for the from currency + cryptoData, exists := response.Data[fromCode] + if !exists { + return RateDTO{}, ErrInvalidResponse + } + + // Get quote for the to currency + quote, exists := cryptoData.Quote[toCode] + if !exists { + return RateDTO{}, ErrInvalidResponse + } + + // Return the rate exactly as API provided it + return RateDTO{ + FromCode: fromCode, + ToCode: toCode, + FromName: cryptoData.Name, + ToName: toCode, + Price: quote.Price, + Source: "coinmarketcap", + }, nil +} diff --git a/infrastructure/coinmarketcap/models.go b/infrastructure/coinmarketcap/models.go new file mode 100644 index 0000000..8193a14 --- /dev/null +++ b/infrastructure/coinmarketcap/models.go @@ -0,0 +1,34 @@ +package coinmarketcap + +// APIResponse represents the top-level CoinMarketCap API response +type APIResponse struct { + Status Status `json:"status"` + Data map[string]CryptoQuote `json:"data"` +} + +// Status represents the API response status +type Status struct { + ErrorCode int `json:"error_code"` + ErrorMessage *string `json:"error_message"` +} + +// CryptoQuote represents cryptocurrency data with quotes +type CryptoQuote struct { + Name string `json:"name"` + Quote map[string]CurrencyQuote `json:"quote"` +} + +// CurrencyQuote represents price quote in a specific currency +type CurrencyQuote struct { + Price float64 `json:"price"` +} + +// RateDTO represents exchange rate data from infrastructure layer +type RateDTO struct { + FromCode string + ToCode string + FromName string + ToName string + Price float64 + Source string +} \ No newline at end of file