diff --git a/src/Service/ApiLayer/CurrencyData.php b/src/Service/ApiLayer/CurrencyData.php index fedee0d..cb0a1d9 100644 --- a/src/Service/ApiLayer/CurrencyData.php +++ b/src/Service/ApiLayer/CurrencyData.php @@ -17,6 +17,7 @@ use Exchanger\Contract\ExchangeRateQuery; use Exchanger\Contract\HistoricalExchangeRateQuery; use Exchanger\Exception\Exception; +use Exchanger\Exception\NonBreakingInvalidArgumentException; use Exchanger\Exception\UnsupportedCurrencyPairException; use Exchanger\ExchangeRate; use Exchanger\Service\HttpService; @@ -25,7 +26,9 @@ use Exchanger\Contract\ExchangeRate as ExchangeRateContract; /** - * ApiLayer Currency Data Service (https://apilayer.com/marketplace/currency_data-api). + * ApiLayer Currency Data Service. + * + * @see https://apilayer.com/marketplace/currency_data-api * * @author Florian Voutzinos */ @@ -45,7 +48,7 @@ final class CurrencyData extends HttpService public function processOptions(array &$options): void { if (!isset($options[self::API_KEY_OPTION])) { - throw new \InvalidArgumentException('The "api_key" option must be provided to use CurrencyData (https://apilayer.com/marketplace/currency_data-api).'); + throw new NonBreakingInvalidArgumentException('The "api_key" option must be provided to use CurrencyData (https://apilayer.com/marketplace/currency_data-api).'); } } diff --git a/src/Service/ApiLayer/ExchangeRatesData.php b/src/Service/ApiLayer/ExchangeRatesData.php new file mode 100755 index 0000000..40070c3 --- /dev/null +++ b/src/Service/ApiLayer/ExchangeRatesData.php @@ -0,0 +1,146 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Exchanger\Service\ApiLayer; + +use Exchanger\Contract\CurrencyPair; +use Exchanger\Contract\ExchangeRate as ExchangeRateContract; +use Exchanger\Contract\ExchangeRateQuery; +use Exchanger\Contract\HistoricalExchangeRateQuery; +use Exchanger\Exception\Exception; +use Exchanger\Exception\NonBreakingInvalidArgumentException; +use Exchanger\Exception\UnsupportedCurrencyPairException; +use Exchanger\ExchangeRate; +use Exchanger\Service\HttpService; +use Exchanger\Service\SupportsHistoricalQueries; +use Exchanger\StringUtil; + +/** + * ApiLayer Exchange Rates Data API. + * + * @see https://apilayer.com/marketplace/exchangerates_data-api + * + * @author Florian Voutzinos + */ +final class ExchangeRatesData extends HttpService +{ + use SupportsHistoricalQueries; + + const API_KEY_OPTION = 'api_key'; + + const LATEST_URL = 'https://api.apilayer.com/exchangerates_data/latest?base=%s&apikey=%s&symbols=%s'; + + const HISTORICAL_URL = 'https://api.apilayer.com/exchangerates_data/%s?base=%s&apikey=%s&symbols=%s'; + + /** + * {@inheritdoc} + */ + public function processOptions(array &$options): void + { + if (!isset($options[self::API_KEY_OPTION])) { + throw new NonBreakingInvalidArgumentException('The "api_key" option must be provided to use Exchange Rates Data (https://apilayer.com/marketplace/exchangerates_data-api).'); + } + } + + /** + * {@inheritdoc} + */ + protected function getLatestExchangeRate(ExchangeRateQuery $exchangeQuery): ExchangeRateContract + { + $currencyPair = $exchangeQuery->getCurrencyPair(); + + $url = sprintf( + self::LATEST_URL, + $currencyPair->getBaseCurrency(), + $this->options[self::API_KEY_OPTION], + $currencyPair->getQuoteCurrency() + ); + + return $this->doCreateRate($url, $currencyPair); + } + + /** + * {@inheritdoc} + */ + protected function getHistoricalExchangeRate(HistoricalExchangeRateQuery $exchangeQuery): ExchangeRateContract + { + $currencyPair = $exchangeQuery->getCurrencyPair(); + + $url = sprintf( + self::HISTORICAL_URL, + $exchangeQuery->getDate()->format('Y-m-d'), + $exchangeQuery->getCurrencyPair()->getBaseCurrency(), + $this->options[self::API_KEY_OPTION], + $currencyPair->getQuoteCurrency() + ); + + return $this->doCreateRate($url, $currencyPair); + } + + /** + * {@inheritdoc} + */ + public function supportQuery(ExchangeRateQuery $exchangeQuery): bool + { + return true; + } + + /** + * Creates a rate. + * + * @param string $url + * + * @throws Exception + */ + private function doCreateRate($url, CurrencyPair $currencyPair): ExchangeRate + { + $content = $this->request($url); + $data = StringUtil::jsonToArray($content); + + if (isset($data['error'])) { + if (isset($data['error']['code'])) { + if (\in_array($data['error']['code'], [ + 'invalid_currency_codes', + 'invalid_base_currency', + 'no_rates_available', + ], true)) { + throw new UnsupportedCurrencyPairException($currencyPair, $this); + } + if (isset($data['error']['message'])) { + throw new Exception($data['error']['message']); + } else { + throw new Exception('Service return error code: '.$data['error']['code']); + } + } else { + throw new Exception('Service return unhandled error'); + } + } + + if (isset($data['rates'][$currencyPair->getQuoteCurrency()])) { + $date = new \DateTime($data['date']); + $rate = $data['rates'][$currencyPair->getQuoteCurrency()]; + + return $this->createRate($currencyPair, (float) $rate, $date); + } + + throw new UnsupportedCurrencyPairException($currencyPair, $this); + } + + /** + * {@inheritdoc} + */ + public function getName(): string + { + return 'apilayer_exchange_rates_data'; + } +} diff --git a/src/Service/ApiLayer/Fixer.php b/src/Service/ApiLayer/Fixer.php index 78a587b..98a3de4 100644 --- a/src/Service/ApiLayer/Fixer.php +++ b/src/Service/ApiLayer/Fixer.php @@ -17,6 +17,7 @@ use Exchanger\Contract\ExchangeRateQuery; use Exchanger\Contract\HistoricalExchangeRateQuery; use Exchanger\Exception\Exception; +use Exchanger\Exception\NonBreakingInvalidArgumentException; use Exchanger\Exception\UnsupportedCurrencyPairException; use Exchanger\ExchangeRate; use Exchanger\Service\HttpService; @@ -25,7 +26,9 @@ use Exchanger\Contract\ExchangeRate as ExchangeRateContract; /** - * ApiLayer Fixer Service (https://apilayer.com/marketplace/fixer-api). + * ApiLayer Fixer Service. + * + * @see https://apilayer.com/marketplace/fixer-api * * @author Florian Voutzinos */ @@ -45,7 +48,7 @@ final class Fixer extends HttpService public function processOptions(array &$options): void { if (!isset($options[self::API_KEY_OPTION])) { - throw new \InvalidArgumentException('The "api_key" option must be provided to use Fixer (https://apilayer.com/marketplace/fixer-api).'); + throw new NonBreakingInvalidArgumentException('The "api_key" option must be provided to use Fixer (https://apilayer.com/marketplace/fixer-api).'); } } diff --git a/src/Service/Registry.php b/src/Service/Registry.php index 92d8b01..57545db 100644 --- a/src/Service/Registry.php +++ b/src/Service/Registry.php @@ -54,7 +54,8 @@ public static function getServices(): array 'abstract_api' => AbstractApi::class, 'exchangeratehost' => ExchangerateHost::class, 'apilayer_fixer' => ApiLayer\Fixer::class, - 'apilayer_currency_data' => ApiLayer\CurrencyData::class + 'apilayer_currency_data' => ApiLayer\CurrencyData::class, + 'apilayer_exchange_rates_data' => ApiLayer\ExchangeRatesData::class ]; } } diff --git a/tests/Fixtures/Service/ApiLayer/ExchangeRatesData/base_currency_access_restricted.json b/tests/Fixtures/Service/ApiLayer/ExchangeRatesData/base_currency_access_restricted.json new file mode 100644 index 0000000..b0113bd --- /dev/null +++ b/tests/Fixtures/Service/ApiLayer/ExchangeRatesData/base_currency_access_restricted.json @@ -0,0 +1,6 @@ +{ + "error":{ + "code":"base_currency_access_restricted", + "message":"An unexpected error ocurred. [Technical Support: support@apilayer.com]" + } +} diff --git a/tests/Fixtures/Service/ApiLayer/ExchangeRatesData/historical.json b/tests/Fixtures/Service/ApiLayer/ExchangeRatesData/historical.json new file mode 100644 index 0000000..7299fe1 --- /dev/null +++ b/tests/Fixtures/Service/ApiLayer/ExchangeRatesData/historical.json @@ -0,0 +1,10 @@ +{ + "success":true, + "timestamp":1618531199, + "historical":true, + "base":"EUR", + "date":"2021-04-15", + "rates":{ + "USD":1.196953 + } +} diff --git a/tests/Fixtures/Service/ApiLayer/ExchangeRatesData/https_access_restricted.json b/tests/Fixtures/Service/ApiLayer/ExchangeRatesData/https_access_restricted.json new file mode 100644 index 0000000..f09f123 --- /dev/null +++ b/tests/Fixtures/Service/ApiLayer/ExchangeRatesData/https_access_restricted.json @@ -0,0 +1,6 @@ +{ + "error":{ + "code":"https_access_restricted", + "message":"Access Restricted - Your current Subscription Plan does not support HTTPS Encryption." + } +} diff --git a/tests/Fixtures/Service/ApiLayer/ExchangeRatesData/invalid_access_key.json b/tests/Fixtures/Service/ApiLayer/ExchangeRatesData/invalid_access_key.json new file mode 100644 index 0000000..416a348 --- /dev/null +++ b/tests/Fixtures/Service/ApiLayer/ExchangeRatesData/invalid_access_key.json @@ -0,0 +1,6 @@ +{ + "error":{ + "code":"invalid_access_key", + "message":"You have not supplied a valid API Access Key. [Technical Support: support@apilayer.com]" + } +} diff --git a/tests/Fixtures/Service/ApiLayer/ExchangeRatesData/invalid_base_currency.json b/tests/Fixtures/Service/ApiLayer/ExchangeRatesData/invalid_base_currency.json new file mode 100644 index 0000000..57ae65e --- /dev/null +++ b/tests/Fixtures/Service/ApiLayer/ExchangeRatesData/invalid_base_currency.json @@ -0,0 +1,6 @@ +{ + "error":{ + "code":"invalid_base_currency", + "message":"An unexpected error ocurred. [Technical Support: support@apilayer.com]" + } +} diff --git a/tests/Fixtures/Service/ApiLayer/ExchangeRatesData/invalid_currency_codes.json b/tests/Fixtures/Service/ApiLayer/ExchangeRatesData/invalid_currency_codes.json new file mode 100644 index 0000000..1726e5c --- /dev/null +++ b/tests/Fixtures/Service/ApiLayer/ExchangeRatesData/invalid_currency_codes.json @@ -0,0 +1,6 @@ +{ + "error":{ + "code":"invalid_currency_codes", + "message":"You have provided one or more invalid Currency Codes. [Required format: currencies=EUR,USD,GBP,...]" + } +} diff --git a/tests/Fixtures/Service/ApiLayer/ExchangeRatesData/invalid_date.json b/tests/Fixtures/Service/ApiLayer/ExchangeRatesData/invalid_date.json new file mode 100644 index 0000000..dedde85 --- /dev/null +++ b/tests/Fixtures/Service/ApiLayer/ExchangeRatesData/invalid_date.json @@ -0,0 +1,6 @@ +{ + "error":{ + "code":"invalid_date", + "message":"You have entered an invalid date. [Required format: date=YYYY-MM-DD]" + } +} diff --git a/tests/Fixtures/Service/ApiLayer/ExchangeRatesData/latest.json b/tests/Fixtures/Service/ApiLayer/ExchangeRatesData/latest.json new file mode 100644 index 0000000..f14e149 --- /dev/null +++ b/tests/Fixtures/Service/ApiLayer/ExchangeRatesData/latest.json @@ -0,0 +1,9 @@ +{ + "success":true, + "timestamp":1619171643, + "base":"EUR", + "date":"2021-04-23", + "rates":{ + "USD":1.20555 + } +} diff --git a/tests/Fixtures/Service/ApiLayer/ExchangeRatesData/no_rates_available.json b/tests/Fixtures/Service/ApiLayer/ExchangeRatesData/no_rates_available.json new file mode 100644 index 0000000..9c9f2b8 --- /dev/null +++ b/tests/Fixtures/Service/ApiLayer/ExchangeRatesData/no_rates_available.json @@ -0,0 +1,6 @@ +{ + "error":{ + "code":"no_rates_available", + "message":"Your query did not return any results. Please try again." + } +} diff --git a/tests/Tests/Service/ApiLayer/CurrencyDataTest.php b/tests/Tests/Service/ApiLayer/CurrencyDataTest.php index aaf110e..2838924 100644 --- a/tests/Tests/Service/ApiLayer/CurrencyDataTest.php +++ b/tests/Tests/Service/ApiLayer/CurrencyDataTest.php @@ -14,6 +14,7 @@ namespace Exchanger\Tests\Service; use Exchanger\Exception\Exception; +use Exchanger\Exception\NonBreakingInvalidArgumentException; use Exchanger\ExchangeRateQuery; use Exchanger\HistoricalExchangeRateQuery; use Exchanger\CurrencyPair; @@ -29,7 +30,7 @@ class CurrencyDataTest extends ServiceTestCase */ public function it_throws_an_exception_if_api_key_option_missing() { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(NonBreakingInvalidArgumentException::class); $this->expectExceptionMessage('The "api_key" option must be provided to use CurrencyData (https://apilayer.com/marketplace/currency_data-api).'); new CurrencyData($this->createMock('Http\Client\HttpClient')); } diff --git a/tests/Tests/Service/ApiLayer/ExchangeRatesDataTest.php b/tests/Tests/Service/ApiLayer/ExchangeRatesDataTest.php new file mode 100644 index 0000000..e7dc15b --- /dev/null +++ b/tests/Tests/Service/ApiLayer/ExchangeRatesDataTest.php @@ -0,0 +1,236 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Exchanger\Tests\Service; + +use Exchanger\CurrencyPair; +use Exchanger\Exception\Exception; +use Exchanger\Exception\NonBreakingInvalidArgumentException; +use Exchanger\Exception\UnsupportedCurrencyPairException; +use Exchanger\ExchangeRateQuery; +use Exchanger\HistoricalExchangeRateQuery; +use Exchanger\Service\ApiLayer\CurrencyData; +use Exchanger\Service\ApiLayer\ExchangeRatesData; + +/** + * @author Florian Voutzinos + */ +class ExchangeRatesDataTest extends ServiceTestCase +{ + /** + * @test + */ + public function it_throws_an_exception_if_api_key_option_missing() + { + $this->expectException(NonBreakingInvalidArgumentException::class); + $this->expectExceptionMessage('The "api_key" option must be provided to use Exchange Rates Data (https://apilayer.com/marketplace/exchangerates_data-api).'); + new ExchangeRatesData($this->createMock('Http\Client\HttpClient')); + } + + /** + * @test + */ + public function it_does_support_all_queries() + { + $service = new ExchangeRatesData( + $this->createMock('Http\Client\HttpClient'), + null, + ['api_key' => 'x'] + ); + $this->assertTrue($service->supportQuery(new ExchangeRateQuery(CurrencyPair::createFromString('USD/EUR')))); + } + + /** + * @test + * @dataProvider unsupportedCurrencyPairResponsesProvider + */ + public function it_throws_An_unsupported_currency_pair_exception( + string $contentPath, + string $uri, + string $accessKey, + string $currencyPair, + bool $historical = false, + string $dateStr = '2020-04-15' + ) { + $this->expectException(UnsupportedCurrencyPairException::class); + + $content = file_get_contents($contentPath); + + $service = new ExchangeRatesData( + $this->getHttpAdapterMock($uri, $content), + null, + ['api_key' => $accessKey] + ); + + if ($historical) { + $date = new \DateTimeImmutable($dateStr); + $query = new HistoricalExchangeRateQuery(CurrencyPair::createFromString($currencyPair), $date); + } else { + $query = new ExchangeRateQuery(CurrencyPair::createFromString($currencyPair)); + } + + $service->getExchangeRate($query); + } + + public function unsupportedCurrencyPairResponsesProvider(): array + { + $dir = __DIR__.'/../../../Fixtures/Service/ExchangeRatesData/'; + + return [ + 'invalid_base_currency' => [ + $dir.'invalid_base_currency.json', + sprintf(ExchangeRatesData::LATEST_URL, $baseCurrency = 'XTS', $accessKey = 'valid', $currency = 'USD'), + $accessKey, + $baseCurrency.'/'.$currency, + ], + 'invalid_currency_codes' => [ + $dir.'invalid_currency_codes.json', + sprintf(ExchangeRatesData::LATEST_URL, $baseCurrency = 'USD', $accessKey = 'valid', $currency = 'XTS'), + $accessKey, + $baseCurrency.'/'.$currency, + ], + 'no_rates_available' => [ + $dir.'no_rates_available.json', + sprintf(ExchangeRatesData::HISTORICAL_URL, $date = '1998-12-31', $baseCurrency = 'USD', $accessKey = 'valid', $currency = 'EUR'), + $accessKey, + $baseCurrency.'/'.$currency, + true, + $date, + ], + ]; + } + + /** + * @dataProvider errorResponsesProvider + */ + public function it_throws_an_exception_with_error_response( + string $contentPath, + string $uri, + string $accessKey, + string $currencyPair, + string $message, + bool $historical = false, + string $dateStr = '2020-04-15' + ) { + $this->expectException(Exception::class); + $this->expectExceptionMessage($message); + + $content = file_get_contents($contentPath); + + $service = new ExchangeRatesData( + $this->getHttpAdapterMock($uri, $content), + null, + ['api_key' => $accessKey] + ); + if ($historical) { + $date = new \DateTimeImmutable($dateStr); + $query = new HistoricalExchangeRateQuery(CurrencyPair::createFromString($currencyPair), $date); + } else { + $query = new ExchangeRateQuery(CurrencyPair::createFromString($currencyPair)); + } + $service->getExchangeRate($query); + } + + public function errorResponsesProvider(): array + { + $dir = __DIR__.'/../../Fixtures/Service/ExchangeRatesData/'; + + return [ + 'invalid_api_key' => [ + $dir.'invalid_api_key.json', + sprintf(ExchangeRatesData::LATEST_URL, $accessKey = 'invalid', $currency = 'USD'), + $accessKey, + 'EUR/'.$currency, + 'You have not supplied a valid API Access Key. [Technical Support: support@apilayer.com]', + ], + 'base_currency_access_restricted' => [ + $dir.'base_currency_access_restricted.json', + sprintf(ExchangeRatesData::LATEST_URL, $baseCurrency = 'USD', $accessKey = 'valid', $currency = 'EUR'), + $accessKey, + $baseCurrency.'/'.$currency, + 'An unexpected error ocurred. [Technical Support: support@apilayer.com]', + ], + 'https_access_restricted' => [ + $dir.'https_access_restricted.json', + sprintf(ExchangeRatesData::LATEST_URL, $baseCurrency = 'EUR', $accessKey = 'valid', $currency = 'USD'), + $accessKey, + $baseCurrency.'/'.$currency, + 'Access Restricted - Your current Subscription Plan does not support HTTPS Encryption.', + ], + 'invalid_date' => [ + $dir.'invalid_date.json', + sprintf(ExchangeRatesData::HISTORICAL_URL, $date = '2056-01-01', $accessKey = 'valid', $currency = 'USD'), + $accessKey, + $baseCurrency.'/'.$currency, + 'You have entered an invalid date. [Required format: date=YYYY-MM-DD]', + true, + $date, + ], + ]; + } + + /** + * @test + */ + public function it_fetches_a_rate() + { + $pair = CurrencyPair::createFromString('EUR/USD'); + $uri = 'https://api.apilayer.com/exchangerates_data/latest?base=EUR&api_key=x&symbols=USD'; + $content = file_get_contents(__DIR__.'/../../Fixtures/Service/ApiLayer/ExchangeRatesData/latest.json'); + + $service = new ExchangeRatesData( + $this->getHttpAdapterMock($uri, $content), + null, + ['api_key' => 'x'] + ); + $rate = $service->getExchangeRate(new ExchangeRateQuery($pair)); + + $this->assertEquals(1.20555, $rate->getValue()); + $this->assertEquals(new \DateTime('2021-04-23'), $rate->getDate()); + $this->assertEquals('apilayer_exchange_rates_data', $rate->getProviderName()); + $this->assertSame($pair, $rate->getCurrencyPair()); + } + + /** + * @test + */ + public function it_fetches_a_historical_rate() + { + $pair = CurrencyPair::createFromString('EUR/USD'); + $uri = 'https://api.apilayer.com/exchangerates_data/2021-04-15?base=EUR&apikey=x&symbols=USD'; + $content = file_get_contents(__DIR__.'/../../../Fixtures/Service/ApiLayer/ExchangeRatesData/historical.json'); + $date = new \DateTime('2021-04-15'); + + $service = new ExchangeRatesData( + $this->getHttpAdapterMock($uri, $content), + null, + ['api_key' => 'x'] + ); + $rate = $service->getExchangeRate(new HistoricalExchangeRateQuery($pair, $date)); + + $this->assertEquals(1.196953, $rate->getValue()); + $this->assertEquals($date, $rate->getDate()); + $this->assertEquals('apilayer_exchange_rates_data', $rate->getProviderName()); + $this->assertSame($pair, $rate->getCurrencyPair()); + } + + /** + * @test + */ + public function it_has_a_name() + { + $service = new ExchangeRatesData($this->createMock('Http\Client\HttpClient'), null, ['api_key' => 'x']); + + $this->assertSame('apilayer_exchange_rates_data', $service->getName()); + } +} diff --git a/tests/Tests/Service/ApiLayer/FixerTest.php b/tests/Tests/Service/ApiLayer/FixerTest.php index 3dceab6..b3423c0 100644 --- a/tests/Tests/Service/ApiLayer/FixerTest.php +++ b/tests/Tests/Service/ApiLayer/FixerTest.php @@ -14,6 +14,7 @@ namespace Exchanger\Tests\Service\ApiLayer; use Exchanger\Exception\Exception; +use Exchanger\Exception\NonBreakingInvalidArgumentException; use Exchanger\ExchangeRateQuery; use Exchanger\HistoricalExchangeRateQuery; use Exchanger\CurrencyPair; @@ -30,7 +31,7 @@ class FixerTest extends ServiceTestCase */ public function it_throws_an_exception_if_api_key_option_missing() { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(NonBreakingInvalidArgumentException::class); $this->expectExceptionMessage('The "api_key" option must be provided to use Fixer (https://apilayer.com/marketplace/fixer-api).'); new Fixer($this->createMock('Http\Client\HttpClient')); }