diff --git a/README.md b/README.md index b2a2d19..ade45f0 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,16 @@ If you are self-hosting Frankfurter or need to use a different endpoint, you can With that task completed, you're ready to start using [frankfurter.dev](https://frankfurter.dev) for retrieving up-to-date exchange rates. +### Frankfurter.dev (v2) + +[frankfurter.dev](https://frankfurter.dev) also offers a v2 API. It is reached through a different endpoint and response +shape than v1, and crucially it covers a much larger currency set than the ECB-only v1 (for example `AED` and `MAD`). +Like v1, it requires no API key. + +In your `exchange.php` config file, set `default` to `frankfurter_v2`, or set `EXCHANGE_DRIVER` to `frankfurter_v2` in your `.env` file. + +If you are self-hosting Frankfurter or need to use a different endpoint, you can set `FRANKFURTER_V2_BASE_URL` in your `.env` file. It defaults to `https://api.frankfurter.dev/v2`. + ### Cache It's unlikely that you want to make a request to a third party service every time you call `Exchange::rates()`. To remedy diff --git a/config/exchange.php b/config/exchange.php index c32d791..b88a080 100644 --- a/config/exchange.php +++ b/config/exchange.php @@ -8,7 +8,7 @@ * Go ahead and select a default exchange driver to be used when * looking up exchange rates. * - * Supported: 'null', 'fixer', 'exchange_rate', 'frankfurter', 'currency_geo', 'cache' + * Supported: 'null', 'fixer', 'exchange_rate', 'frankfurter', 'frankfurter_v2', 'currency_geo', 'cache' */ 'default' => env('EXCHANGE_DRIVER', 'frankfurter'), @@ -64,6 +64,22 @@ 'base_url' => env('FRANKFURTER_BASE_URL', 'https://api.frankfurter.dev/v1'), ], + /* + |-------------------------------------------------------------------------- + | Frankfurter.dev (v2) + |-------------------------------------------------------------------------- + | + | The v2 API uses a different endpoint/response shape than v1 and covers + | a much larger currency set (e.g. AED, MAD). It needs no API key. To use + | it, set 'default' to 'frankfurter_v2' or set EXCHANGE_DRIVER to + | 'frankfurter_v2' in your .env file. + | + */ + + 'frankfurter_v2' => [ + 'base_url' => env('FRANKFURTER_V2_BASE_URL', 'https://api.frankfurter.dev/v2'), + ], + /* |-------------------------------------------------------------------------- | Cache diff --git a/src/ExchangeRateProviders/FrankfurterV2Provider.php b/src/ExchangeRateProviders/FrankfurterV2Provider.php new file mode 100644 index 0000000..64cfea0 --- /dev/null +++ b/src/ExchangeRateProviders/FrankfurterV2Provider.php @@ -0,0 +1,84 @@ + $rows */ + $rows = $this->client() + ->get('/rates', [ + 'base' => $baseCurrency, + 'quotes' => implode(',', $currencies), + ]) + ->throw() + ->json(); + + $rates = []; + + foreach ($rows as $row) { + $rates[strval($row['quote'])] = floatval($row['rate']); + } + + /** @var non-empty-array $rates */ + return new Rates( + $baseCurrency, + $rates, + $this->getRetrievedAt($rows[0]['date'] ?? null), + ); + } + + private function client(): PendingRequest + { + return $this->client + ->baseUrl($this->baseUrl) + ->asJson() + ->acceptJson(); + } + + private function getRetrievedAt(?string $date): CarbonImmutable + { + if ($date === null) { + throw new InvalidArgumentException('The returned date could not be parsed.'); + } + + // The leading "!" resets the time to 00:00:00 so the date cannot roll + // over when the instance is converted to the Europe/Amsterdam timezone. + $carbonInstance = CarbonImmutable::createFromFormat('!Y-m-d', $date); + + if ($carbonInstance === null) { + throw new InvalidArgumentException('The returned date could not be parsed.'); + } + + return $carbonInstance->timezone('Europe/Amsterdam')->setTime(16, 0, 0); + } +} diff --git a/src/Support/ExchangeRateManager.php b/src/Support/ExchangeRateManager.php index fe67077..474cac6 100644 --- a/src/Support/ExchangeRateManager.php +++ b/src/Support/ExchangeRateManager.php @@ -13,6 +13,7 @@ use Worksome\Exchange\ExchangeRateProviders\ExchangeRateHostProvider; use Worksome\Exchange\ExchangeRateProviders\FixerProvider; use Worksome\Exchange\ExchangeRateProviders\FrankfurterProvider; +use Worksome\Exchange\ExchangeRateProviders\FrankfurterV2Provider; use Worksome\Exchange\ExchangeRateProviders\NullProvider; final class ExchangeRateManager extends Manager @@ -80,6 +81,16 @@ public function createFrankfurterDriver(): FrankfurterProvider ); } + public function createFrankfurterV2Driver(): FrankfurterV2Provider + { + $baseUrl = $this->config->string('exchange.services.frankfurter_v2.base_url', 'https://api.frankfurter.dev/v2'); + + return new FrankfurterV2Provider( + $this->container->make(Factory::class), + $baseUrl, + ); + } + public function createCacheDriver(): CachedProvider { /** @var CacheFactory $factory */ diff --git a/tests/Feature/Support/ExchangeRateManagerTest.php b/tests/Feature/Support/ExchangeRateManagerTest.php index c9f4f2b..9debc0f 100644 --- a/tests/Feature/Support/ExchangeRateManagerTest.php +++ b/tests/Feature/Support/ExchangeRateManagerTest.php @@ -7,6 +7,7 @@ use Worksome\Exchange\ExchangeRateProviders\ExchangeRateHostProvider; use Worksome\Exchange\ExchangeRateProviders\FixerProvider; use Worksome\Exchange\ExchangeRateProviders\FrankfurterProvider; +use Worksome\Exchange\ExchangeRateProviders\FrankfurterV2Provider; use Worksome\Exchange\ExchangeRateProviders\NullProvider; use Worksome\Exchange\Support\ExchangeRateManager; @@ -27,6 +28,7 @@ ['fixer', FixerProvider::class], ['exchange_rate', ExchangeRateHostProvider::class], ['frankfurter', FrankfurterProvider::class], + ['frankfurter_v2', FrankfurterV2Provider::class], ['currency_geo', CurrencyGEOProvider::class], ['cache', CachedProvider::class], ]); diff --git a/tests/Unit/ExchangeRateProviders/FrankfurterV2ProviderTest.php b/tests/Unit/ExchangeRateProviders/FrankfurterV2ProviderTest.php new file mode 100644 index 0000000..783aef8 --- /dev/null +++ b/tests/Unit/ExchangeRateProviders/FrankfurterV2ProviderTest.php @@ -0,0 +1,97 @@ + + */ +function v2Rows(?string $date = null): array +{ + $date ??= now()->subDay()->format('Y-m-d'); + + return [ + ['date' => $date, 'base' => 'EUR', 'quote' => 'EUR', 'rate' => 1], // int -> should be cast to float + ['date' => $date, 'base' => 'EUR', 'quote' => 'GBP', 'rate' => 2.5], + ]; +} + +it('is able to make a real call to the API', function () { + $client = new Factory(); + $provider = new FrankfurterV2Provider($client); + $rates = $provider->getRates('EUR', currencies()); + + expect($rates)->toBeInstanceOf(Rates::class); +})->group('integration'); + +it('makes a HTTP request to the correct endpoint', function () { + $client = new Factory(); + $client->fake(['*' => v2Rows()]); + + $provider = new FrankfurterV2Provider($client); + $provider->getRates('EUR', currencies()); + + $client->assertSent(function (Request $request) { + return str_starts_with($request->url(), 'https://api.frankfurter.dev/v2/rates'); + }); +}); + +it('maps the v2 row shape into a quote => rate array', function () { + $client = new Factory(); + $client->fake(['*' => v2Rows()]); + + $provider = new FrankfurterV2Provider($client); + $rates = $provider->getRates('EUR', currencies()); + + expect($rates->rates)->toBe(['EUR' => 1.0, 'GBP' => 2.5]); +}); + +it('returns floats for all rates', function () { + $client = new Factory(); + $client->fake(['*' => v2Rows()]); + + $provider = new FrankfurterV2Provider($client); + $rates = $provider->getRates('EUR', currencies()); + + expect($rates->rates)->each->toBeFloat(); +}); + +it('sets the returned timestamp as the retrievedAt timestamp', function () { + Carbon::setTestNow(now()); + + $client = new Factory(); + $client->fake(['*' => v2Rows(now()->subDay()->format('Y-m-d'))]); + + $provider = new FrankfurterV2Provider($client); + $rates = $provider->getRates('EUR', currencies()); + + expect($rates->retrievedAt->format('Ymd'))->toBe(now()->subDay()->format('Ymd')); +}); + +it('makes a HTTP request to a custom base url', function () { + $client = new Factory(); + $client->fake(['*' => v2Rows()]); + + $provider = new FrankfurterV2Provider($client, 'https://custom.frankfurter.dev/v2'); + $provider->getRates('EUR', currencies()); + + $client->assertSent(function (Request $request) { + return str_starts_with($request->url(), 'https://custom.frankfurter.dev/v2/rates'); + }); +}); + +it('throws a RequestException if a 500 error occurs', function () { + $client = new Factory(); + $client->fake(['*' => Create::promiseFor(new Response(500))]); + + $provider = new FrankfurterV2Provider($client); + $provider->getRates('EUR', currencies()); +})->throws(RequestException::class);