Using an interchangeable API and caching results
Along with the technology development, the need to migrate, or at least incorporate e-commerce to the business, plays a big role for companies of all sizes nowadays. This new modality aims for the clients’ comfort because they don’t have to go out of their homes to get what they want. Not to mention the impact that COVID had daily life worldwide.
At first, this strategy started for local stores or even national ones, but currently it is extended to the whole planet. What does that mean? Well, someone may want to buy something which is sold at the other end of the world and the seller will get a specific amount of money in their country’s currency for the purchased item.
Ok, but if we want the clients’ comfort, as we said, why would we force them to convert every price they see online manually to actually know how much it costs? If we want them to buy the products, let’s be clear about the prices: showing them in the currency that the clients need, is a good start!
Rates source
First of all, we should define which source will provide us with all the required rates and information we need to create our service. There are several options, which can be in file or API format, paid or free, with more or less information. You should research which one is suitable for your project and needs.
For the sake of simplicity of this post, I will use Exchange Rates API, that is also very similar to Rates API. These APIs use the European Central Bank information to generate their data, and both of them are free.
Basic Currency Exchange Service
As I will be developing this example with Django, I suggest you to be familiar with the framework and its programming language: Python.
No matter what your project is about, I would recommend you to create a currency app typing this command in your terminal:
./manage.py startapp currency
We will make sure that the code related to currency will be stored in one place.
We want to have a class that handles an amount’s conversion from one currency to another using the chosen rates resource that we mentioned before. Let’s see how the Exchange Rates API works!
Making the following API call, we’ll get the latest foreign exchange reference rates:
GET https://api.exchangeratesapi.io/latest?base=EUR HTTP/1.1{
"rates": {
"CAD": 1.5243,
"HKD": 8.4286,
"ISK": 158.3,
"PHP": 54.545,
"DKK": 7.4556,
"HUF": 353.58,
"CZK": 27.408,
"AUD": 1.6687,
"RON": 4.8353,
"SEK": 10.5843,
"IDR": 16092.26,
"INR": 81.8825,
"BRL": 6.3606,
"RUB": 79.5893,
"HRK": 7.5705,
"JPY": 116.28,
"THB": 34.844,
"CHF": 1.0528,
"SGD": 1.538,
"PLN": 4.5636,
"BGN": 1.9558,
"TRY": 7.5861,
"CNY": 7.7102,
"NOK": 10.938,
"NZD": 1.7983,
"ZAR": 19.919,
"USD": 1.0875,
"MXN": 26.2304,
"ILS": 3.819,
"GBP": 0.88245,
"KRW": 1331.08,
"MYR": 4.704
},
"base": "EUR",
"date": "2020-05-13"
}
How do we use this information?
Basically, our class should have a method that will receive:
- The base currency, which is the default currency of our ecommerce.
- The converted currency, which is the client’s currency.
- The amount to be converted (from base currency to converted currency).
Let’s call this method convert. If both currencies are the same, it will return the given amount as it is, then no conversion has to be made. Otherwise, it will make and API call to the selected rates resource, getting the rate in question and making the right mathematical operations to obtain the converted result. It will look something like this:
import requests
from django.conf import settings
class CurrencyExchangeService:
def get_rates_from_api(self, base_currency):
url = f'{settings.CURRENCY_RATES_URL}?base={base_currency}'
return requests.get(url).json()
def get_rate(self, base_currency, currency):
return self.get_rates_from_api(base_currency)['rates'][currency]
def convert(self, amount, currency, base_currency):
if base.upper() == convert_currency.upper():
return amount
return round(float(amount) * float(self.get_rate(base.upper(), convert_currency.upper())), 3)
Using django-money
There is a good Django library called django-money which makes it easier to manipulate money all along your application, because it encapsulates the amount and the currency in one object of class Money, among other cool stuff. This sounds perfect for our currency exchange service. Let’s incorporate the concept of money to it!
import requests
from django.conf import settings
from djmoney.money import Money
class CurrencyExchangeService:
def get_rates_from_api(self, base_currency):
url = f'{settings.CURRENCY_RATES_URL}?base={base_currency}'
return requests.get(url).json()
def get_rate(self, base_currency, currency):
return self.get_rates_from_api(base_currency)['rates'][currency]
def get_converted_amount(amount, base_currency, converted_currency):
return round(float(amount) * float(self.get_rate(base_currency.upper(), convert_currency.upper())), 3)
def validate_money(money):
if not isinstance(money, Money):
raise Exception('A Money instance must be provided')
def convert(self, money, currency):
self.validate_money(money)
amount = money.amount
base_currency = str(money.currency)
converted_currency = str(currency)
if base_currency.upper() == convert_currency.upper():
return money
return Money(self.get_converted_amount(amount, base_currency, converted_currency), converted_currency)
As you can see, some changes were introduced:
- Instead of receiving amount, base currency and converted currency, we’ll now receive just a money object and the currency. From the money object, we can extract the amount and the base currency.
- In order to do what we explained in the previous item, we must make sure that the money parameter is actually an instance of the Money class. That’s why we validate it at the beginning and raise an exception if it doesn’t satisfy our requirements.
- The currency parameter can be either a string or a currency object (from django-money), but to use it to access the rates response, we have to make sure that all currencies are casted to strings.
- Instead of returning an amount, we return a money object now, which is more cohesive with the new implementation.
Caching results
Great! We already have a currency exchange service which works with an external API that provides us the currency rates. It’s time to go a little bit further and make it better.
Currency rates, excluding cryptocurrencies at least, don’t tend to vary as fast and as much in the short term as actions of stock exchanges, for example. That means, that it is not necessary to consult our rates API every time we want to convert a price. Not only it’s unnecessary, but it is also better if we avoid API calls because they are expensive and could make our application less performant. And if all of these was not enough, think about the rates API we are currently using. They are giving us a free service and it would be nice if we didn’t saturate their servers with unnecessary work.
Did I convince you? How about caching the rates for a certain time to avoid these API calls?
I decided to use Redis to cache my data. It is an in-memory data structure store, used as a database, cache and message broker.
If you are using Docker and Docker Compose to run your application, you should put this in your docker-compose.yml:
version: "3.1"
services:
redis:
image: redis:6.0-rc1-alpine
restart: always
ports:
- "6379:6379"
And in your settings.py:
CACHES = {
"default": {
"BACKEND": "redis_cache.cache.RedisCache",
"LOCATION": "redis://redis:6379/1",
'TIMEOUT': 86400,
"OPTIONS": {
"CLIENT_CLASS": "redis_cache.client.DefaultClient",
}
}
}
Note: Bare in mind that if you’re running Redis locally, you should change your configuration to this "LOCATION": "redis://127.0.0.1:6379/1"
And finally, our currency exchange service will look something like this:
import requests
from django.conf import settings
from django.core.cache import cache
from djmoney.money import Money
class CurrencyExchangeService:
def get_rates_from_api(self, base_currency):
url = f'{settings.CURRENCY_RATES_URL}?base={base_currency}'
return requests.get(url).json()
def get_key(self, base_currency):
return f'currencies-{base_currency}'
def get_all_rates(self, base_currency):
key = self.get_key(base_currency)
return cache.get_or_set(key, lambda: self.get_rates_from_api(base_currency))
def get_rate(self, base_currency, currency):
return self.get_all_rates(base_currency)['rates'][currency]
def get_converted_amount(amount, base_currency, converted_currency):
return round(float(amount) * float(self.get_rate(base_currency.upper(), convert_currency.upper())), 3)
def validate_money(money):
if not isinstance(money, Money):
raise Exception('A Money instance must be provided')
def convert(self, money, currency):
self.validate_money(money)
amount = money.amount
base_currency = str(money.currency)
converted_currency = str(currency)
if base_currency.upper() == convert_currency.upper():
return money
return Money(self.get_converted_amount(amount, base_currency, converted_currency), converted_currency)
When we need to access to a rate, we will first check if that information is in the cache. If not, we will make the API call and set the cache for upcoming requests.
Last but not least: testing
It is always a good practice to write some unit tests to check that the code we wrote is working fine. Let’s see how our tests will look like!
import responses
from django.conf import settings
from django.test import override_settings
from django.core.cache import cache
from djmoney.money import Money
from currency.currency_exchange_service import CurrencyExchangeService
EUR_BASE_RESPONSE = {
"rates": {
"CHF": 1.0715,
"USD": 1.1003,
"GBP": 0.84835
},
"base": "EUR",
"date": "2020-02-06"
}
@override_settings(CACHES={
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
}
})
class CurrencyExchangeServiceTestCase(BaseTestCase):
def setUp(self):
super().setUp()
self.exchanger = CurrencyExchangeService()
def tearDown(self):
cache.clear()
@responses.activate
def test_convert_from_eur_to_gbp(self):
responses.add(responses.GET, f'{settings.CURRENCY_RATES_URL}?base=EUR',
json=EUR_BASE_RESPONSE, status=200)
self.assertEquals(self.exchanger.convert(Money(15, 'EUR'), 'GBP').amount, 12.725)
@responses.activate
def test_convert_use_cache(self):
responses.add(responses.GET, f'{settings.CURRENCY_RATES_URL}?base=EUR',
json=EUR_BASE_RESPONSE, status=200)
self.assertEquals(self.exchanger.convert(Money(15, 'EUR'), 'GBP').amount, 12.725)
self.assertEquals(len(responses.calls), 1)
self.assertEquals(self.exchanger.convert(Money(20, 'EUR'), 'GBP').amount, 16.967)
self.assertEquals(len(responses.calls), 1)
def test_same_currency(self):
self.assertEquals(self.exchanger.convert(Money(15, 'EUR'), 'EUR').amount, 15)
For this currency exchange service, there are two tricky factors that we have to handle:
Caching in tests
We want a different cache, that only has data from our tests and not from the productive environment.
To do that, we will have to override the Redis cache with a Local Memory Cache, that uses our local memory. We are not using a Dummy Cache because we actually want it to store the rates to test them.
@override_settings(CACHES={
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
}
})
Note that we will have to clear the cache after every test to have it clean for upcoming tests.
Mocking the API calls
We have to mock our API calls to the rates source, in order to be independent. If that API changes the rates, we will still know that our application does what it has to do.
We can achieve that by using a Django’s library called responses. To indicate Django that we want to use the library, we need to add before each test the following decorator:
@responses.activate
We will have a constant called EUR_BASE_RESPONSE which stores some rates, always respecting the API’s response structure, that will be passed to the responses object to mock the actual response.
You can also see that we can check how many times the mocked endpoint was called, which is useful to test if our service is actually caching the data.
Summing up
We have created a flexible currency exchange service which converts money from one currency to another and allows us to easily change the currencies’ rates source. In addition to that, it caches those rates to make our application faster. Cool right? 😉
Note that if you use django-money, you could extend its default exchange backend to create this service, or maybe you could use an existing conversion library. What I wanted to show with this post is the step by step to clarify some concepts, but feel free to use what suits you best!
Thank you very much for reading this post and I hope it helps you for your projects. Follow me for upcoming posts, and I wish you good luck and happy coding!
Hey, if you’ve found this useful, please share the post to help other folks find it: