Skip to content
Merged
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
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
[![Total Downloads](https://img.shields.io/packagist/dt/florianv/exchanger.svg?style=flat-square)](https://packagist.org/packages/florianv/exchanger)
[![Version](http://img.shields.io/packagist/v/florianv/exchanger.svg?style=flat-square)](https://packagist.org/packages/florianv/exchanger)

> _Exchange rate provider layer for PHP. Direct access to 30 provider implementations through a single `ExchangeRateService` interface, with chain fallback and PSR-16 caching. Maintained since 2016._
> _Exchange rate provider layer for PHP. Direct access to 31 provider implementations through a single `ExchangeRateService` interface, with chain fallback and PSR-16 caching. Maintained since 2016._

<table>
<tr>
Expand All @@ -21,12 +21,12 @@
</tr>
</table>

Exchanger is the **exchange rate provider layer** for PHP — 30 services (commercial APIs like its sponsor **[fastFOREX](https://www.fastforex.io)**, the European Central Bank, several national banks, exchangerate.host) behind a single `ExchangeRateService` interface, with chainable fallback, PSR-16 caching and historical rates. For most use cases the higher-level [Swap](https://github.com/florianv/swap) library is what you want; reach for Exchanger when you need finer control.
Exchanger is the **exchange rate provider layer** for PHP — 31 services (commercial APIs like its sponsor **[fastFOREX](https://www.fastforex.io)**, the European Central Bank, several national banks, exchangerate.host) behind a single `ExchangeRateService` interface, with chainable fallback, PSR-16 caching and historical rates. For most use cases the higher-level [Swap](https://github.com/florianv/swap) library is what you want; reach for Exchanger when you need finer control.

## 💡 What is Exchanger?

- A PHP library for currency conversion and exchange rate retrieval at the provider layer.
- 30 service implementations behind a common `ExchangeRateService` interface.
- 31 service implementations behind a common `ExchangeRateService` interface.
- PSR-16 SimpleCache support.
- Historical rates.
- A chain service for fallback. When a service errors, the next one in the chain is tried.
Expand Down Expand Up @@ -119,7 +119,7 @@ Services are tried in order. If a service does not support the requested currenc

## 📊 Providers

Exchanger ships 30 exchange rate provider implementations. Each is registered in `Exchanger\Service\Registry` under the **identifier** shown below.
Exchanger ships 31 exchange rate provider implementations. Each is registered in `Exchanger\Service\Registry` under the **identifier** shown below.

### Commercial providers (require an API key)

Expand All @@ -141,6 +141,7 @@ Exchanger ships 30 exchange rate provider implementations. Each is registered in
| Fixer (direct) | `fixer` | EUR (free), * (paid) | * | Yes |
| 1Forge | `forge` | * | * | No |
| Open Exchange Rates | `open_exchange_rates` | USD (free), * (paid) | * | Yes |
| UniRateAPI | `unirate_api` | * | * | Yes (paid) |
| WebserviceX | `webservicex` | * | * | No |
| xChangeApi.com | `xchangeapi` | * | * | Yes |
| Xignite | `xignite` | * | * | Yes |
Expand Down
1 change: 1 addition & 0 deletions doc/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ Commercial services take an HTTP client, a request factory (both can be `null`),
| `Exchanger\Service\FixerApiLayer` | `api_key` | |
| `Exchanger\Service\Forge` | `api_key` | |
| `Exchanger\Service\OpenExchangeRates` | `app_id` | `enterprise` (bool) |
| `Exchanger\Service\UniRateApi` | `api_key` | |
| `Exchanger\Service\XchangeApi` | `api-key` | (note the hyphen) |
| `Exchanger\Service\Xignite` | `token` | |

Expand Down
9 changes: 9 additions & 0 deletions psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -861,6 +861,15 @@
<code><![CDATA[empty($elements)]]></code>
</RiskyTruthyFalsyComparison>
</file>
<file src="src/Service/UniRateApi.php">
<MissingOverrideAttribute>
<code><![CDATA[protected function getHistoricalExchangeRate(HistoricalExchangeRateQuery $exchangeQuery): ExchangeRateContract]]></code>
<code><![CDATA[public function getLatestExchangeRate(ExchangeRateQuery $exchangeQuery): ExchangeRateContract]]></code>
<code><![CDATA[public function getName(): string]]></code>
<code><![CDATA[public function processOptions(array &$options): void]]></code>
<code><![CDATA[public function supportQuery(ExchangeRateQuery $exchangeQuery): bool]]></code>
</MissingOverrideAttribute>
</file>
<file src="src/Service/WebserviceX.php">
<MissingOverrideAttribute>
<code><![CDATA[public function getExchangeRate(ExchangeRateQuery $exchangeQuery): ExchangeRateContract]]></code>
Expand Down
3 changes: 2 additions & 1 deletion src/Service/Registry.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ public static function getServices(): array
'exchangeratehost' => ExchangerateHost::class,
'apilayer_fixer' => ApiLayer\Fixer::class,
'apilayer_currency_data' => ApiLayer\CurrencyData::class,
'apilayer_exchange_rates_data' => ApiLayer\ExchangeRatesData::class
'apilayer_exchange_rates_data' => ApiLayer\ExchangeRatesData::class,
'unirate_api' => UniRateApi::class
];
}
}
126 changes: 126 additions & 0 deletions src/Service/UniRateApi.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php

declare(strict_types=1);

/*
* This file is part of Exchanger.
*
* (c) Florian Voutzinos <florian@voutzinos.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Exchanger\Service;

use Exchanger\Contract\CurrencyPair;
use Exchanger\Contract\ExchangeRateQuery;
use Exchanger\Contract\HistoricalExchangeRateQuery;
use Exchanger\Exception\Exception;
use Exchanger\Exception\UnsupportedCurrencyPairException;
use Exchanger\ExchangeRate;
use Exchanger\StringUtil;
use Exchanger\Contract\ExchangeRate as ExchangeRateContract;

/**
* UniRateAPI Service.
*
* @see https://unirateapi.com
*/
final class UniRateApi extends HttpService
{
use SupportsHistoricalQueries;

const API_KEY_OPTION = 'api_key';

const LATEST_URL = 'https://api.unirateapi.com/api/rates?api_key=%s&from=%s&to=%s';

const HISTORICAL_URL = 'https://api.unirateapi.com/api/historical/rates?api_key=%s&date=%s&from=%s&to=%s';

/**
* {@inheritdoc}
*/
public function processOptions(array &$options): void
{
if (!isset($options[self::API_KEY_OPTION])) {
throw new \InvalidArgumentException('The "api_key" option must be provided.');
}
}

/**
* {@inheritdoc}
*/
public function supportQuery(ExchangeRateQuery $exchangeQuery): bool
{
return true;
}

/**
* {@inheritdoc}
*/
public function getLatestExchangeRate(ExchangeRateQuery $exchangeQuery): ExchangeRateContract
{
$currencyPair = $exchangeQuery->getCurrencyPair();
$url = sprintf(
self::LATEST_URL,
urlencode((string) $this->options[self::API_KEY_OPTION]),
$currencyPair->getBaseCurrency(),
$currencyPair->getQuoteCurrency()
);

return $this->doCreateRate($url, $currencyPair, new \DateTime());
}

/**
* {@inheritdoc}
*/
protected function getHistoricalExchangeRate(HistoricalExchangeRateQuery $exchangeQuery): ExchangeRateContract
{
$currencyPair = $exchangeQuery->getCurrencyPair();
$url = sprintf(
self::HISTORICAL_URL,
urlencode((string) $this->options[self::API_KEY_OPTION]),
$exchangeQuery->getDate()->format('Y-m-d'),
$currencyPair->getBaseCurrency(),
$currencyPair->getQuoteCurrency()
);

return $this->doCreateRate($url, $currencyPair, $exchangeQuery->getDate());
}

/**
* @throws Exception
*/
private function doCreateRate(string $url, CurrencyPair $currencyPair, \DateTimeInterface $date): ExchangeRate
{
$response = $this->getResponse($url, ['Accept' => 'application/json']);

try {
$data = StringUtil::jsonToArray($response->getBody()->__toString());
} catch (\Throwable $thrown) {
$data = ['error' => 'Failed to parse response'];
}

if ($response->getStatusCode() !== 200 || isset($data['error'])) {
$message = isset($data['error']) && is_string($data['error'])
? $data['error']
: sprintf('Failed with HTTP response code %d', $response->getStatusCode());

throw new Exception($message);
}

if (!isset($data['rate']) || (!is_string($data['rate']) && !is_numeric($data['rate']))) {
throw new UnsupportedCurrencyPairException($currencyPair, $this);
}

return $this->createRate($currencyPair, (float) $data['rate'], $date);
}

/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'unirate_api';
}
}
3 changes: 3 additions & 0 deletions tests/Fixtures/Service/UniRateApi/error-invalid-currency.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"error": "Currency not found or no data available"
}
3 changes: 3 additions & 0 deletions tests/Fixtures/Service/UniRateApi/historical-usd-eur.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"rate": "0.91"
}
3 changes: 3 additions & 0 deletions tests/Fixtures/Service/UniRateApi/rate-usd-eur.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"rate": "0.925"
}
118 changes: 118 additions & 0 deletions tests/Tests/Service/UniRateApiTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php

declare(strict_types=1);

/*
* This file is part of Exchanger.
*
* (c) Florian Voutzinos <florian@voutzinos.com>
*
* 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\Exception\Exception;
use Exchanger\Exception\UnsupportedCurrencyPairException;
use Exchanger\HistoricalExchangeRateQuery;
use Exchanger\CurrencyPair;
use Exchanger\ExchangeRateQuery;
use Exchanger\Service\UniRateApi;
use PHPUnit\Framework\Attributes\Test;

class UniRateApiTest extends ServiceTestCase
{
#[Test]
public function it_throws_an_exception_when_api_key_option_missing()
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('api_key');

new UniRateApi($this->createMock('Http\Client\HttpClient'));
}

#[Test]
public function it_supports_all_queries()
{
$service = new UniRateApi($this->createMock('Http\Client\HttpClient'), null, ['api_key' => 'secret']);

$this->assertTrue($service->supportQuery(new ExchangeRateQuery(CurrencyPair::createFromString('EUR/USD'))));
$this->assertTrue($service->supportQuery(new HistoricalExchangeRateQuery(CurrencyPair::createFromString('EUR/USD'), new \DateTime())));
}

#[Test]
public function it_fetches_a_latest_rate()
{
$pair = CurrencyPair::createFromString('USD/EUR');
$url = 'https://api.unirateapi.com/api/rates?api_key=secret&from=USD&to=EUR';
$content = file_get_contents(__DIR__.'/../../Fixtures/Service/UniRateApi/rate-usd-eur.json');
$service = new UniRateApi($this->getHttpAdapterMock($url, $content), null, ['api_key' => 'secret']);

$rate = $service->getExchangeRate(new ExchangeRateQuery($pair));

$this->assertSame(0.925, $rate->getValue());
$this->assertSame('unirate_api', $rate->getProviderName());
$this->assertSame($pair, $rate->getCurrencyPair());
}

#[Test]
public function it_fetches_a_historical_rate()
{
$pair = CurrencyPair::createFromString('USD/EUR');
$date = new \DateTime('2024-01-15');
$url = 'https://api.unirateapi.com/api/historical/rates?api_key=secret&date=2024-01-15&from=USD&to=EUR';
$content = file_get_contents(__DIR__.'/../../Fixtures/Service/UniRateApi/historical-usd-eur.json');
$service = new UniRateApi($this->getHttpAdapterMock($url, $content), null, ['api_key' => 'secret']);

$rate = $service->getExchangeRate(new HistoricalExchangeRateQuery($pair, $date));

$this->assertSame(0.91, $rate->getValue());
$this->assertSame('2024-01-15', $rate->getDate()->format('Y-m-d'));
$this->assertSame('unirate_api', $rate->getProviderName());
$this->assertSame($pair, $rate->getCurrencyPair());
}

#[Test]
public function it_throws_an_exception_when_response_has_error_field()
{
$this->expectException(Exception::class);
$this->expectExceptionMessage('Currency not found or no data available');

$url = 'https://api.unirateapi.com/api/rates?api_key=secret&from=USD&to=ZZZ';
$content = file_get_contents(__DIR__.'/../../Fixtures/Service/UniRateApi/error-invalid-currency.json');
$service = new UniRateApi($this->getHttpAdapterMock($url, $content, 404), null, ['api_key' => 'secret']);

$service->getExchangeRate(new ExchangeRateQuery(CurrencyPair::createFromString('USD/ZZZ')));
}

#[Test]
public function it_throws_an_exception_when_response_status_error()
{
$this->expectException(Exception::class);

$url = 'https://api.unirateapi.com/api/rates?api_key=secret&from=USD&to=EUR';
$service = new UniRateApi($this->getHttpAdapterMock($url, '', 401), null, ['api_key' => 'secret']);

$service->getExchangeRate(new ExchangeRateQuery(CurrencyPair::createFromString('USD/EUR')));
}

#[Test]
public function it_throws_an_exception_when_rate_field_missing()
{
$this->expectException(UnsupportedCurrencyPairException::class);

$url = 'https://api.unirateapi.com/api/rates?api_key=secret&from=USD&to=EUR';
$service = new UniRateApi($this->getHttpAdapterMock($url, '{}'), null, ['api_key' => 'secret']);

$service->getExchangeRate(new ExchangeRateQuery(CurrencyPair::createFromString('USD/EUR')));
}

#[Test]
public function it_has_a_name()
{
$service = new UniRateApi($this->createMock('Http\Client\HttpClient'), null, ['api_key' => 'secret']);

$this->assertSame('unirate_api', $service->getName());
}
}