Currency Exchange Microservice with Webtask.io

The finance data-table in Yahoo APIs has recently stopped working, returning error "No definition found for Table yahoo.finance.xchange". I need currency exchange rates for my money tracking app, so I had to look for another provider.

After googling around I found CurrencyLayer - a service which provides exchange rates for 168 currencies and even have a free plan. This plan is limited with 1,000 requests per month.

I’ve used webtask.io before in other projects and I got an idea to write a microservice which would cache exchange rate from CurrencyLayer. I would only need to cache USD-based rates and calculate non-USD based rates using cross-rate. Webtasks provide a single JSON document storage which would be perfect for our use-case.

If we cache the exchange rate for 1 hour, it will use max. 744 requests per month (24 hours x 31 days), which should fit into the free plan 😉

Design

The microservice will have a very simple interface - an HTTP GET endpoint with a single pairs parameter. This parameter will containt a list of currency exchange pairs separated by comma. Sample URL request would look like this:

curl -XGET https://endpoint-url/?pairs=USDEUR,EURUSD,USDJPY

and will respond with a list of exchange rates for given pairs:

{
    "ok": "true",
    "rates": [
       {
         "id": "USDEUR",
         "rate": "0.834303"
       },
       {
         "id": "EURUSD",
         "rate": "1.198605"
       },
       {
         "id": "USDJPY",
         "rate": "109.678001"
       }
    ]
}

Implementation

Implementing this is pretty straight forward.

A webtask is just a JavaScript function with two arguments.

First, context - gives us access to URL query parameters, webtask secrets (to keep our API keys separated from code) and storage API to cache data.

Second, respond - is a callback function which we should call when we’re finished. It takes an error and result arguments and converts them to JSON.

Here’s the code of this webtask.

module.exports = function(context, respond) {
  const request = require('request')
  const BASE = 'USD'
  const pairs = context.query.pairs.toUpperCase().split(',')

  return getBaseRate()
    .then(baseRate =>
      respond(null, {
        ok: true,
        rates: pairs.map(pair => getRateForPair(baseRate, pair))
      })
    )
    .catch(error => respond(error))

  /**
   * Get exchange rate for given pair using given base exchange rate.
   * Use cross rate if pair's base is not equal to rate base (USD).
   *
   * @param {object} baseRate - dict { USD: 1, EUR: 0.834499, ... }
   * @param {string} pair - "USDEUR", "EURUSD", "EURJPY", etc
   * @return {object} dict { id: "USDEUR", rate: "0.834499" }
   */
  function getRateForPair(baseRate, pair) {
    if (pair.length != 6) {
      throw new Error(
        `Invalid pair "${pair}". Must be 6-char string, e.g. "USDEUR"`
      )
    }

    const source = pair.substr(0, 3)
    const target = pair.substr(3, 3)

    if (!baseRate[source]) throw new Error(`Unknown currency code "${source}"`)
    if (!baseRate[target]) throw new Error(`Unknown currency code "${target}"`)

    return {
      id: pair,
      rate: Number(
        source === BASE
          ? baseRate[target]
          : 1 / baseRate[source] * baseRate[target]
      ).toFixed(6)
    }
  }

  /**
   * Get exchange rate for base currency (USD).
   *
   * @return {object} dict { USD: 1, EUR: 0.834499, ... }
   */
  function getBaseRate() {
    return fetchCachedRate()
      .then(rate => rate, error => fetchLiveRate())
      .then(checkCachedRateAge)
      .then(convertRate)
  }

  /**
   * Read cached rate from webtask storage.
   *
   * @see https://webtask.io/docs/storage
   * @return {Promise}
   */
  function fetchCachedRate() {
    return new Promise((resolve, reject) => {
      context.storage.get((error, rate) => {
        if (error) return reject(error)
        if (rate === undefined) return reject()

        resolve(rate)
      })
    })
  }

  /**
   * Fetch base exchange rate from CurrencyLayer live API.
   * Fallback to cached rate if API is not available.
   *
   * @see https://currencylayer.com/documentation
   * @see https://webtask.io/docs/editor/secrets
   * @return {Promise}
   */
  function fetchLiveRate() {
    return new Promise(resolve => {
      const apiKey = context.secrets.apiKey
      request(
        {
          method: 'GET',
          uri: `http://apilayer.net/api/live?access_key=${apiKey}`,
          json: true
        },
        (error, response, body) => {
          if (error || !body.success) {
            fetchCachedRate().then(rate => resolve(rate))
          } else {
            writeCachedRate(body).then(() => resolve(body))
          }
        }
      )
    })
  }

  /**
   * Write given rate to webtask cache.
   *
   * @see https://webtask.io/docs/storage
   * @param {object} rate
   * @return {Promise}
   */
  function writeCachedRate(rate) {
    return new Promise((resolve, reject) => {
      context.storage.set(
        rate,
        { force: 1 },
        error => (error ? reject(error) : resolve())
      )
    })
  }

  /**
   * Validate cached rate expiry date.
   *
   * @param {object} rate
   */
  function checkCachedRateAge(rate) {
    const expiryDate = Math.floor(Date.now() / 1000) - 3600
    return rate.timestamp < expiryDate ? fetchLiveRate() : rate
  }

  /**
   * Convert response from API service to internal rate object.
   *
   * @param {object} dict { ..., quotes: { USDUSD: 1, USDEUR: 0.834499, ... } }
   * @return {object} dict { USD: 1, EUR: 0.834499, ... }
   */
  function convertRate(rate) {
    return Object.keys(rate.quotes).reduce((acc, pair) => {
      const code = pair.substr(3, 3)
      acc[code] = rate.quotes[pair]
      return acc
    }, {})
  }
}

You can check my webtask running here.

If you plan to use this approach in your own projects - I suggest getting your own CurrencyLayer account and running your own webtask.

Written on August 30, 2017