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 }