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 @@ -86,6 +86,16 @@ Set `CURRENCY_GEO_ACCESS_KEY` to your provided access key from currency.getgeoap
With that task completed, you're ready to start using [Currency.GetGeoApi.com](https://currency.getgeoapi.com) for retrieving up-to-date
exchange rates.

### AllRatesToday.com

[AllRatesToday.com](https://allratestoday.com) is an alternative option for mid-market rates. A free key comes with a one-time quota of 300 requests; after that a paid plan is needed.

In your `exchange.php` config file, set `default` to `all_rates_today`, or set `EXCHANGE_DRIVER` to `all_rates_today` in your `.env` file.
Set `ALL_RATES_TODAY_ACCESS_KEY` to your API key from allratestoday.com.

With that task completed, you're ready to start using [AllRatesToday.com](https://allratestoday.com) for retrieving up-to-date
exchange rates.

### Frankfurter.dev

[frankfurter.dev](https://frankfurter.dev) is an open-source API for current and historical foreign exchange rates, which can be used without an API key.
Expand Down
17 changes: 16 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', 'currency_geo', 'all_rates_today', 'cache'
*/

'default' => env('EXCHANGE_DRIVER', 'frankfurter'),
Expand Down Expand Up @@ -60,6 +60,21 @@
'access_key' => env('CURRENCY_GEO_ACCESS_KEY'),
],

/*
|--------------------------------------------------------------------------
| AllRatesToday.com
|--------------------------------------------------------------------------
|
| AllRatesToday is a paid service for mid-market exchange rates. To use it,
| you'll need an API key from allratestoday.com. Set that here, and then change
| the 'default' to 'all_rates_today' or set EXCHANGE_DRIVER to 'all_rates_today'.
|
*/

'all_rates_today' => [
'access_key' => env('ALL_RATES_TODAY_ACCESS_KEY'),
],

/*
|--------------------------------------------------------------------------
| Frankfurter.dev
Expand Down
66 changes: 66 additions & 0 deletions src/ExchangeRateProviders/AllRatesTodayProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?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 Illuminate\Support\Collection;
use Worksome\Exchange\Contracts\ExchangeRateProvider;
use Worksome\Exchange\Support\Rates;

final readonly class AllRatesTodayProvider implements ExchangeRateProvider
{
public function __construct(
private Factory $client,
private string $accessKey,
private string $baseUrl = 'https://allratestoday.com/api/v1',
) {
}

/**
* @throws RequestException
*/
public function getRates(string $baseCurrency, array $currencies): Rates
{
$data = $this->makeRequest($baseCurrency, $currencies);

$time = $data->pluck('time')->filter()->first();

return new Rates(
$baseCurrency,
// @phpstan-ignore argument.type
$data->mapWithKeys(fn (array $value) => [$value['target'] => floatval($value['rate'])])->all(),
is_string($time) ? CarbonImmutable::parse($time) : now()->startOfDay(),
);
}

/**
* @param array<int, string> $currencies
*
* @return Collection<int, array{source: string, target: string, rate: numeric, time?: string}>
*
* @throws RequestException
*/
private function makeRequest(string $baseCurrency, array $currencies): Collection
{
return $this->client()
->get('/rates', [
'source' => $baseCurrency,
'target' => implode(',', $currencies),
])
->throw()
->collect();
}

private function client(): PendingRequest
{
return $this->client
->baseUrl($this->baseUrl)
->withToken($this->accessKey)
->acceptJson();
}
}
15 changes: 15 additions & 0 deletions src/Support/ExchangeRateManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Illuminate\Http\Client\Factory;
use Illuminate\Support\Manager;
use Worksome\Exchange\Exceptions\InvalidConfigurationException;
use Worksome\Exchange\ExchangeRateProviders\AllRatesTodayProvider;
use Worksome\Exchange\ExchangeRateProviders\CachedProvider;
use Worksome\Exchange\ExchangeRateProviders\CurrencyGEOProvider;
use Worksome\Exchange\ExchangeRateProviders\ExchangeRateHostProvider;
Expand Down Expand Up @@ -70,6 +71,20 @@ public function createCurrencyGeoDriver(): CurrencyGEOProvider
);
}

public function createAllRatesTodayDriver(): AllRatesTodayProvider
{
$apiKey = $this->config->get('exchange.services.all_rates_today.access_key');

throw_unless(is_string($apiKey), new InvalidConfigurationException(
'You haven\'t set up an API key for AllRatesToday!'
));

return new AllRatesTodayProvider(
$this->container->make(Factory::class),
$apiKey,
);
}

public function createFrankfurterDriver(): FrankfurterProvider
{
$baseUrl = $this->config->string('exchange.services.frankfurter.base_url', 'https://api.frankfurter.dev/v2');
Expand Down
10 changes: 10 additions & 0 deletions tests/Feature/Support/ExchangeRateManagerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

use Worksome\Exchange\Contracts\ExchangeRateProvider;
use Worksome\Exchange\Exceptions\InvalidConfigurationException;
use Worksome\Exchange\ExchangeRateProviders\AllRatesTodayProvider;
use Worksome\Exchange\ExchangeRateProviders\CachedProvider;
use Worksome\Exchange\ExchangeRateProviders\CurrencyGEOProvider;
use Worksome\Exchange\ExchangeRateProviders\ExchangeRateHostProvider;
Expand All @@ -14,6 +15,7 @@
config()->set('exchange.services.fixer.access_key', 'password');
config()->set('exchange.services.exchange_rate.access_key', 'password');
config()->set('exchange.services.currency_geo.access_key', 'password');
config()->set('exchange.services.all_rates_today.access_key', 'password');
});

it('can instantiate all drivers', function (string $driver, string $expectedClass) {
Expand All @@ -28,6 +30,7 @@
['exchange_rate', ExchangeRateHostProvider::class],
['frankfurter', FrankfurterProvider::class],
['currency_geo', CurrencyGEOProvider::class],
['all_rates_today', AllRatesTodayProvider::class],
['cache', CachedProvider::class],
]);

Expand All @@ -53,3 +56,10 @@
$manager = new ExchangeRateManager($this->app);
$manager->driver('currency_geo');
})->throws(InvalidConfigurationException::class, 'You haven\'t set up an API key for CurrencyGEO!');

it('will throw the right exception if no AllRatesToday API key has been configured', function () {
config()->set('exchange.services.all_rates_today.access_key', null);

$manager = new ExchangeRateManager($this->app);
$manager->driver('all_rates_today');
})->throws(InvalidConfigurationException::class, 'You haven\'t set up an API key for AllRatesToday!');
75 changes: 75 additions & 0 deletions tests/Unit/ExchangeRateProviders/AllRatesTodayProviderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?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\AllRatesTodayProvider;
use Worksome\Exchange\Support\Rates;

it('is able to make a real call to the API', function () {
$client = new Factory();
$allRatesTodayProvider = new AllRatesTodayProvider($client, getenv('ALL_RATES_TODAY_ACCESS_KEY'));
$rates = $allRatesTodayProvider->getRates('EUR', currencies());

expect($rates)->toBeInstanceOf(Rates::class);
})
->skip(
in_array(getenv('ALL_RATES_TODAY_ACCESS_KEY'), [false, ''], true),
'No ALL_RATES_TODAY_ACCESS_KEY was defined.'
)
->group('integration');

it('makes a HTTP request to the correct endpoint', function () {
$client = new Factory();
$client->fake(['*' => [
['source' => 'EUR', 'target' => 'GBP', 'rate' => 0.85, 'time' => '2026-10-22T12:00:00Z'],
]]);

$allRatesTodayProvider = new AllRatesTodayProvider($client, 'password');
$allRatesTodayProvider->getRates('EUR', ['GBP', 'USD']);

$client->assertSent(function (Request $request) {
return str_starts_with($request->url(), 'https://allratestoday.com/api/v1/rates')
&& $request['source'] === 'EUR'
&& $request['target'] === 'GBP,USD'
&& $request->hasHeader('Authorization', 'Bearer password');
});
});

it('returns floats keyed by target currency', function () {
$client = new Factory();
$client->fake(['*' => [
['source' => 'EUR', 'target' => 'EUR', 'rate' => 1, 'time' => '2026-10-22T12:00:00Z'],
['source' => 'EUR', 'target' => 'GBP', 'rate' => '0.85', 'time' => '2026-10-22T12:00:00Z'],
]]);

$allRatesTodayProvider = new AllRatesTodayProvider($client, 'password');
$rates = $allRatesTodayProvider->getRates('EUR', currencies());

expect($rates->rates)
->toBe(['EUR' => 1.0, 'GBP' => 0.85])
->each->toBeFloat();
});

it('sets the returned time as the retrievedAt timestamp', function () {
$client = new Factory();
$client->fake(['*' => [
['source' => 'EUR', 'target' => 'GBP', 'rate' => 0.85, 'time' => '2026-10-22T12:00:00Z'],
]]);

$allRatesTodayProvider = new AllRatesTodayProvider($client, 'password');
$rates = $allRatesTodayProvider->getRates('EUR', currencies());

expect($rates->retrievedAt->timestamp)->toBe(Carbon::parse('2026-10-22T12:00:00Z')->timestamp);
});

it('throws a RequestException if a 401 error occurs', function () {
$client = new Factory();
$client->fake(['*' => Create::promiseFor(new Response(401))]);

$allRatesTodayProvider = new AllRatesTodayProvider($client, 'password');
$allRatesTodayProvider->getRates('EUR', currencies());
})->throws(RequestException::class);