December 17, 2014

building a currency exchange rates api

While working on umsatz I had to build a tiny currency exchange rates api in go.
While the original API works fine, it clocks in at more than 400 lines of code. Let’s write a shorter version!

In this post we’ll re-build the api, in three steps:

  1. download, parse & cache the EUR exchange rates history xml, provided by the ECB 1
  2. add a tiny HTTP JSON API to request rates
  3. periodically update the cache with new data

This will leave use with a tiny, HTTP currency exchange rates api.

Let’s start by downloading, parsing & caching the EUR exchange xml:

package main

import (
  "encoding/xml"
  "fmt"
  "io"
  "net/http"
)

// these structs reflect the eurofxref xml data structure
type envelop struct {
  Subject string `xml:"subject"`
  Sender  string `xml:"Sender>name"`
  Cubes   []cube `xml:"Cube>Cube"`
}
type cube struct {
  Date      string     `xml:"time,attr"`
  Exchanges []exchange `xml:"Cube"`
}
type exchange struct {
  Currency string  `xml:"currency,attr"`
  Rate     float32 `xml:"rate,attr"`
}

// EUR is not present because all exchange rates are a reference to the EUR
var desiredCurrencies = map[string]struct{}{
  "USD": struct{}{},
  "GBP": struct{}{},
}
var eurHistURL = "http://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist-90d.xml"
var exchangeRates = map[string][]exchange{}

func downloadExchangeRates() (io.Reader, error) {
  resp, err := http.Get(eurHistURL)
  if err != nil {
    return nil, err
  }

  if resp.StatusCode != http.StatusOK {
    return nil, fmt.Errorf("HTTP request returned %v", resp.Status)
  }

  return resp.Body, nil
}

func filterExchangeRates(c *cube) []exchange {
  var rates []exchange
  for _, ex := range c.Exchanges {
    if _, ok := desiredCurrencies[ex.Currency]; ok {
      rates = append(rates, ex)
    }
  }
  return rates
}

func updateExchangeRates(data io.Reader) error {
  var e envelop
  decoder := xml.NewDecoder(data)
  if err := decoder.Decode(&e); err != nil {
    return err
  }

  for _, c := range e.Cubes {
    if _, ok := exchangeRates[c.Date]; !ok {
      exchangeRates[c.Date] = filterExchangeRates(&c)
    }
  }

  return nil
}

func init() {
  if reader, err := downloadExchangeRates(); err != nil {
    fmt.Printf("Unable to download exchange rates. Is the URL correct?")
  } else {
    if err := updateExchangeRates(reader); err != nil {
      fmt.Printf("Failed to update exchange rates: %v", err)
    }
  }
}

func main() {
  fmt.Println("%v", exchangeRates)
}

There are a few things to note:

  • we’re using a map[string]struct{} to define which currencies we’re interested in.
    This adds a little more code since we have to filter the exchange rates, but also cuts down memory usage.
  • we cache all exchange rates in memory and never update them. Since we’re dealing with historic data only this shouldn’t be a problem.

Next, we add a tiny HTTP wrapper:

// accept strings like /1986-09-03 and /1986-09-03/USD
var routingRegexp = regexp.MustCompile(`/(\d{4}-\d{2}-\d{2})/?([A-Za-z]{3})?`)

func exchangeRatesByCurrency(rates []exchange) map[string]float32 {
  var mappedByCurrency = make(map[string]float32)
  for _, rate := range rates {
    mappedByCurrency[rate.Currency] = rate.Rate
  }
  return mappedByCurrency
}

func newCurrencyExchangeServer() http.Handler {
  r := http.NewServeMux()

  r.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
    if !routingRegexp.MatchString(req.URL.Path) {
      w.WriteHeader(http.StatusBadRequest)
      return
    }

    parts := routingRegexp.FindAllStringSubmatch(req.URL.Path, -1)[0]
    requestedDate := parts[1]
    requestedCurrency := parts[2]

    enc := json.NewEncoder(w)
    if _, ok := exchangeRates[requestedDate]; !ok {
      w.WriteHeader(http.StatusNotFound)
      return
    }

    var exs = exchangeRates[requestedDate]
    if requestedCurrency == "" {
      enc.Encode(exchangeRatesByCurrency(exs))
    } else {
      for _, rate := range exs {
        if rate.Currency == parts[2] {
          enc.Encode(rate)
          return
        }
      }

      w.WriteHeader(http.StatusNotFound)
    }
  })

  return http.Handler(r)
}

func main() {
  log.Printf("listening on :8080")
  log.Fatal(http.ListenAndServe(":8080", newCurrencyExchangeServer()))
}

We can now run the API and it’ll work just fine:

$ curl http://127.0.0.1:8080/2014-12-12
{"GBP":0.7925,"USD":1.245}
$ curl http://127.0.0.1:8080/2014-12-12/USD
{"currency":"USD","rate":1.245}

Adding a period cache updater is quickly done:

func updateExchangeRatesCache() {
  if reader, err := downloadExchangeRates(); err != nil {
    fmt.Printf("Unable to download exchange rates. Is the URL correct?")
  } else {
    if err := updateExchangeRates(reader); err != nil {
      fmt.Printf("Failed to update exchange rates: %v", err)
    }
  }
}

func updateExchangeRatesPeriodically() {
  for {
    time.Sleep(1 * time.Hour)

    updateExchangeRatesCache()
  }
}

func init() {
  updateExchangeRatesCache()
}

func main() {
  go updateExchangeRatesPeriodically()

  log.Printf("listening on :8080")
  log.Fatal(http.ListenAndServe(":8080", newCurrencyExchangeServer()))
}

The API will populate the cache on startup and update it once per hour afterwards.

How much memory does this consume? Let’s check:

func updateExchangeRates(data io.Reader) error {
  var e envelop
  decoder := xml.NewDecoder(data)
  if err := decoder.Decode(&e); err != nil {
    return err
  }

  for _, c := range e.Cubes {
    if _, ok := exchangeRates[c.Date]; !ok {
      exchangeRates[c.Date] = filterExchangeRates(c)
    }
  }

  runtime.GC()

  return nil
}

func printMemoryUsage() {
  var memStats runtime.MemStats
  runtime.ReadMemStats(&memStats)

  fmt.Printf("total memory usage: %2.2f MB\n", float32(memStats.Alloc)/1024./1024.)
}

func main() {
  go updateExchangeRatesPeriodically()

  printMemoryUsage()
  log.Printf("listening on :8080")
  log.Fatal(http.ListenAndServe(":8080", newCurrencyExchangeServer()))
}

Note the new call to runtime.GC() which forces a garbage collection. This is important to get a correct memory usage report, otherwise we’d get varying and thus wrong memory usage reports.

Turns out the memory footprint is acceptable, without any optimizations:

  • all data since 1999, all currencies: 5.137 MB
  • all data since 1999, only USD and GBP: 0.836 MB
  • last 90 days, all currencies: 0.211 MB
  • last 90 days, only USD and GBP: 0.137 MB

Let’s wrap it up:

In less than 200 lines of code we managed to create a fully functional currency exchange rates api. Compared to the original version we do not cache exchange rates to disk, in favor of keeping everything in memory. This reduces the total lines of code considerably and also removes the need for a separate importer binary.

The API is not perfect, however:

  • the data source does not contain data for weekends as well as holidays. For anything production ready we’d want to write a fallback which serves old exchange rates instead of just returning a 404.

However, I’ll leave it for now. You can find the entire source in this gist.

1: If anyone knows a higher precision, open data source for history currency exchange rates I’d love to know. Leave a comment.

© Raphael Randschau 2010 - 2022 | Impressum