Vladimir
Posted on July 25, 2022
I recently did a test task on Symfony - currency converter with direct and cross conversion. So I want to share the result with the community as an example of a simple console application according to Symfony's rules: DI, autowiring, service tagging, flexible configuration, that's all. I hope this will be useful for beginners.
The application calculates "currency exchange" at direct rates (for example, USD -> EUR), as well as through "intermediate" currencies (for example, BTC -> USD -> EUR). There are also fake rates for tests.
Rates are taken from ecb.europa.eu (major world currencies against EUR) and coindesk.com (BTC against USD).
Triangulation is based on the principles from http://www.dpxo.net/articles/fx_rate_triangulation_sql.html.
The data storing in the SQLite database.
The application can be used through local PHP or in Docker.
PHP requirements: version 8.1+, bcmath, ctype, iconv, intl, pdo_sqlite, simplexml, sqlite3 extensions.
I had little experience with Symfony (I worked with Laravel mostly), so there may be some flaws.
In addition, SQLite imposed some limitations due to the lack of real decimal and numeric formats, and INSERT IGNORE, the calculation accuracy of 16.8 had to be hardcoded.
There was trouble with the ECB rates' dates, so the application uses the last available day from each source.
Highlights
Commands
The application has two console commands: "currency:update" - updating exchange rates (\App\Command\CurrencyUpdateCommand
) and "currency:exchange" - exchangу currencies (\App\Command\CurrencyExchangeCommand
).
The commands accept parameters, validate data, pass them to services, catch exceptions and beautifully print the result to the console with the appropriate exit status.
All services and providers are passed through constructor injection. Rate providers have been tagged with the "app.rates_provider" tag in config/services.yaml and passed through an iterator to \App\Services\RatesUpdater
by this tag. Very convenient, I think.
App\Providers\CoinDeskRatesProvider:
tags: [ 'app.rates_provider' ]
App\Providers\EcbRatesProvider:
tags: [ 'app.rates_provider' ]
App\Services\RatesUpdater:
arguments:
- !tagged_iterator app.rates_provider
class RatesUpdater
{
public function __construct(private readonly iterable $ratesProviders, ...)
{
}
...
}
Data exchange and validation
Data for currency exchange and saving rates are sent via DTO: \App\Dto\Exchange
and \App\Dto\Rate
, respectively.
Validation of "AmountRequirements" - quantity requirements and "ExchangeCurrencyRequirements" - currency requirements are imposed on DTO for currency exchange.
In addition, validation applies to the \App\Entity\Pair
and \App\Entity\Rate
entities.
All validators are custom to hide unnecessary details from consumers. Validators locate in the src/Validator/
classes. Most of them are compounds of simple rules. For example, the quantity requirements are "Non-empty string", "Numeric type", and "Positive value".
class AmountRequirements extends Compound
{
protected function getConstraints(array $options): array
{
return [
new Assert\NotBlank(),
new Assert\Type(type: 'numeric', message: 'The value {{ value }} is not a valid {{ type }}'),
new Assert\Positive(),
];
}
}
There is also a more complex currency existence validator \App\Validator\PairCurrencyExistValidator
. It accesses the currency pair repository and checks the database for SELECT COUNT(1) FROM pair WHERE base = <passed currency ticker>
. Its realized via Doctrine Query Builder.
Exchange rates update
Everything is quite simple here: \App\Services\RatesUpdater
receives an iterator of currency rate providers in the constructor and calls them one by one (via __invoke, so you don't need to invent a method name). All providers inherit the \App\Providers\RatesProvider
abstract class and implement their data transformation methods in the \App\Dto\Rate
DTO.
The abstract provider asks for rates at the address specified in the configuration and .env, which is embedded in the constructor and the base currency's name. Then the provider parses rates from JSON or XML into a simple array and passes them to the provider-specific transformer.
Parsers locate in src/Parsers/
.
For tests, \App\Providers\FakeRatesProvider
is used with an overridden fetch method and a couple of rates wired into it.
The rates received in the form of DTO are stored in the database in direct and reverse form, after which the triangulator \App\Services\RatesTriangulator
is put into operation. It creates all possible combinations of rates through intermediate currencies (so-called cross rates) and records them in the \App\Entity\Pair
entity.
Triangulation is based on the principles of http://www.dpxo.net/articles/fx_rate_triangulation_sql.html. It is much easier to get one pair of currencies for conversion from a separate table with currency pairs than calculate rates for each conversion.
If something goes wrong, then the providers or the triangulator throw exceptions.
Using
If you have PHP installed locally, you need to clone the repository, install packages, create a database, perform migrations and update exchange rates.
git clone https://github.com/vladimirmartsul/symfony-exchange-demo.git
cd symfony-exchange-demo
composer install --no-dev --no-interaction
php bin/console doctrine:database:create
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console currency:update
Calculate exchange
php bin/console currency:exchange <amount> <from> <to>
For example
php bin/console currency:exchange 2 EUR BTC
should output
[OK] 2 EUR is 0.00005254 BTC
You can also build and run the application in Docker
git clone https://github.com/vladimirmartsul/symfony-exchange-demo.git
cd symfony-exchange-demo
docker compose up --build
The exchange rates are loaded during the build.
Calculate exchange
docker compose run symfony-exchange-demo currency:exchange <amount> <from> <to>
For example
docker compose run symfony-exchange-demo currency:exchange 2 EUR BTC
Should output the same result as the local PHP run.
Testing
A couple of tests have been written for the application to make sure that the main functionality works correctly. The tests use a mocked provider of exchange rates.
\App\Tests\Command\CurrencyUpdateCommandTest
- a simple check for messages about successful download, triangulation and update of courses.
\App\Tests\Command\CurrencyExchangeCommandTest
- a little more complicated: checking the real conversion using a dataProvider with several currency pairs and the expected result. Each time the test is run, the exchange rates are updated.
You can run tests locally by installing additional dev packages.
cd symfony-exchange-demo
echo APP_ENV=test > .env.local
composer install --no-interaction
php bin/console doctrine:database:create
php bin/console doctrine:migrations:migrate --no-interaction
php bin/phpunit
Or similar with the Docker.
cd symfony-exchange-demo
echo APP_ENV=test > .env.local
docker compose run symfony-exchange-demo composer install --no-interaction
docker compose run symfony-exchange-demo doctrine:database:create
docker compose run symfony-exchange-demo doctrine:migrations:migrate --no-interaction
docker compose run symfony-exchange-demo bin/phpunit
Welcome to comments and pull requests :-)
Posted on July 25, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.