Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion config/exchange.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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
Expand Down
84 changes: 84 additions & 0 deletions src/ExchangeRateProviders/FrankfurterV2Provider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

declare(strict_types=1);

namespace Worksome\Exchange\ExchangeRateProviders;

use Carbon\CarbonImmutable;
use Illuminate\Http\Client\Factory;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use InvalidArgumentException;
use Worksome\Exchange\Contracts\ExchangeRateProvider;
use Worksome\Exchange\Support\Rates;

/**
* Frankfurter v2 provider.
*
* The v2 API differs from v1: rates are requested from `/v2/rates` with
* `base` / `quotes` query parameters and returned as a list of rows shaped
* `{ date, base, quote, rate }` rather than a `{ rates: { CODE: rate } }`
* object. v2 also exposes a much larger currency set than the ECB-only v1
* (e.g. AED, MAD), so it cannot be reached by simply swapping the base URL.
*/
final readonly class FrankfurterV2Provider implements ExchangeRateProvider
{
public function __construct(
private Factory $client,
private string $baseUrl = 'https://api.frankfurter.dev/v2',
) {
}

/**
* @throws RequestException
*/
public function getRates(string $baseCurrency, array $currencies): Rates
{
/** @var list<array{date: string, base: string, quote: string, rate: float|int}> $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<string, float> $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);
}
}
11 changes: 11 additions & 0 deletions src/Support/ExchangeRateManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 */
Expand Down
2 changes: 2 additions & 0 deletions tests/Feature/Support/ExchangeRateManagerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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],
]);
Expand Down
97 changes: 97 additions & 0 deletions tests/Unit/ExchangeRateProviders/FrankfurterV2ProviderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php

use Carbon\Carbon;
use GuzzleHttp\Promise\Create;
use GuzzleHttp\Psr7\Response;
use Illuminate\Http\Client\Factory;
use Illuminate\Http\Client\Request;
use Illuminate\Http\Client\RequestException;
use Worksome\Exchange\ExchangeRateProviders\FrankfurterV2Provider;
use Worksome\Exchange\Support\Rates;

/**
* The v2 API returns a list of rows shaped { date, base, quote, rate }.
*
* @return array<int, array{date: string, base: string, quote: string, rate: float|int}>
*/
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);