index.js

/**
 * An API wrapper around exchangeratesapi.io
 *
 * @module exchange-rate-api
 * @author over-engineer
 */

require('isomorphic-fetch');
const { getYear, isAfter } = require('date-fns');

const ExchangeRatesError = require('./exchange-rates-error');
const QueryStringBuilder = require('./query-string-builder');
const currencies = require('./currencies');
const utils = require('./utils');

const API_BASE_URL = 'https://api.exchangeratesapi.io';

/**
 * ExchangeRates
 */
class ExchangeRates {
    constructor() {
        this._base = null;          // base currency, defaults to 'EUR'
        this._symbols = null;       // exchange rates to fetch
        this._from = 'latest';      // date from which to request historical rates
        this._to = null;            // date to which to request historical rates
    }

    /**
     * Throw an error if the given currency is not supported
     *
     * @throws {ExchangeRatesError}     Will throw an error if the given currency
     *                                  doesn't exist in the currencies object
     */
    _validateCurrency(currency) {
        if (!(currency in currencies)) {
            throw new ExchangeRatesError(`${currency} is not a valid currency`);
        }
    }

    /**
     * Determine whether this request should call the /history endpoint or not
     *
     * @return {boolean}                Whether this a history request or not
     */
    _isHistoryRequest() {
        return this._to !== null;
    }

    /**
     * Throw an error if any of our validations fails
     *
     * @throws {ExchangeRatesError}     Will throw an error if any validation fails
     */
    _validate() {
        if (this._isHistoryRequest()) {
            if (this._from === 'latest')
                throw new ExchangeRatesError('Cannot set the \'from\' date to \'latest\' when fetching a date range');
            if (isAfter(this._from, this._to))
                throw new ExchangeRatesError('The \'from\' date cannot be after the \'to\' date');
        }

        if (this._from !== 'latest' && getYear(this._from) < 1999)
            throw new ExchangeRatesError('Cannot get historical rates before 1999');
    }

    /**
     * Build and return the url to request
     *
     * @return {string}                 The url to request
     */
    _buildUrl() {
        let url = API_BASE_URL + '/';
        let qs = new QueryStringBuilder();

        if (this._isHistoryRequest()) {
            url += 'history';
            qs.addParam('start_at', utils.formatDate(this._from));
            qs.addParam('end_at', utils.formatDate(this._to));
        } else {
            url += (this._from === 'latest') ? 'latest' : utils.formatDate(this._from);
        }

        if (this._base)
            qs.addParam('base', this._base);
        if (this._symbols)
            qs.addParam('symbols', this._symbols.join(','), false);

        return url + qs;
    }

    /**
     * Set the date to get historical rates for that specific day
     *
     * @param {Date} date               The date to request its historical rates
     * @return {ExchangeRates}          The instance on which this method was called
     */
    at(date) {
        this._from = utils.parseDate(date);
        return this;    // chainable
    }

    /**
     * Set the date to get the latest exchange rates
     *
     * @return {ExchangeRates}          The instance on which this method was called
     */
    latest() {
        this._from = 'latest';
        return this;    // chainable
    }

    /**
     * Set the date from which to request historical rates
     *
     * @param {Date} date               The date from which to request historical rates
     * @return {ExchangeRates}          The instance on which this method was called
     */
    from(date) {
        this._from = utils.parseDate(date);
        return this;    // chainable
    }

    /**
     * Set the date to which to request historical rates
     *
     * @param {Date} date               The date to which to request historical rates
     * @return {ExchangeRates}          The instance on which this method was called
     */
    to(date) {
        this._to = utils.parseDate(date);
        return this;    // chainable
    }

    /**
     * Set the base currency (if not explicitly set, it defaults to 'EUR')
     *
     * @param {string} currency         The base currency
     * @return {ExchangeRates}          The instance on which this method was called
     */
    base(currency) {
        if (typeof currency !== 'string')
            throw new TypeError('Base currency has to be a string');

        currency = currency.toUpperCase();
        this._validateCurrency(currency);

        this._base = currency;
        return this;    // chainable
    }

    /**
     * Set symbols to limit results to specific exchange rate(s)
     *
     * @param {string|string[]} currencies      The currency (or an array of currencies)
     * @return {ExchangeRates}                  The instance on which this method was called
     */
    symbols(currencies) {
        currencies = Array.isArray(currencies) ? currencies : [currencies];

        for (let i = 0; i < currencies.length; i++) {
            let currency = currencies[i];

            if (typeof currency !== 'string')
                throw new TypeError('Symbol currencies have to be strings');

            currency = currency.toUpperCase();
            this._validateCurrency(currency);

            currencies[i] = currency;
        }

        this._symbols = currencies;
        return this;    // chainable
    }

    /**
     * The API url to request
     * @type {string}
     */
    get url() {
        this._validate();
        return this._buildUrl();
    }

    /**
     * Fetch the exchange rates from exchangeratesapi.io, parse the response and return it
     *
     * @return {Promise<object|number>}     A Promise that when resolved, returns either an
     *                                      object containing the exchange rates, or a number
     *                                      if the response contains a single exchange rate
     */
    fetch() {
        this._validate();

        return fetch(this._buildUrl())
            .then(response => {
                if (response.status !== 200)
                    throw new ExchangeRatesError(`API returned a bad response (HTTP ${response.status})`);
                return response.json();
            })
            .then(data => {
                const keys = Object.keys(data.rates);
                return (keys.length === 1) ? data.rates[keys[0]] : data.rates;
            })
            .catch(err => {
                throw new ExchangeRatesError(`Couldn't fetch the exchange rate, ${err.message}`);
            });
    }

    /**
     * Return the average value of each exchange rate for the selected time period
     * To select a time period, create a chain with `.from()` and `.to()` before `.avg()`
     *
     * @param {?number} decimalPlaces       If set, it limits the number of decimal places
     * @return {Promise<object|number>}     A Promise that when resolved, returns either an
     *                                      object containing the average value of each rate
     *                                      for the selected time period, or a number if
     *                                      a single date was selected
     */
    avg(decimalPlaces = null) {
        if (decimalPlaces !== null && !Number.isInteger(decimalPlaces))
            throw new ExchangeRatesError('The decimal places parameter has to be an integer');
        if (decimalPlaces !== null && decimalPlaces < 0)
            throw new ExchangeRatesError('Decimal places cannot be negative');

        return this.fetch().then(rates => {
            if (!this._isHistoryRequest()) return rates;

            let mergedObj = {};
            Object.values(rates).forEach(obj => {
                Object.keys(obj).forEach(key => {
                    mergedObj[key] = mergedObj[key] || [];
                    mergedObj[key].push(obj[key]);
                });
            });

            let avgRates = {};
            const keys = Object.keys(mergedObj);
            keys.forEach(key => {
                let avgRate = mergedObj[key].reduce((p, c) => p + c, 0) / mergedObj[key].length;
                avgRates[key] = (decimalPlaces === null) ? avgRate : +avgRate.toFixed(decimalPlaces);
            });

            return (keys.length === 1) ? avgRates[keys[0]] : avgRates;
        });
    }
}

/**
 * Convert the given amount from one currency to another using the exchange rate of the given date
 *
 * @param {number} amount                   The amount to convert
 * @param {string} fromCurrency             Which currency to convert from
 * @param {string} toCurrency               Which currency to convert to
 * @param {Date|string} [date='latest']     The date to request the historic rate
 *                                          (if omitted, defaults to 'latest')
 * @return {number}                         The converted amount
 */
const convert = (amount, fromCurrency, toCurrency, date = 'latest') => {
    if (typeof amount !== 'number')
        throw new TypeError('The \'amount\' parameter has to be a number');
    if (Array.isArray(toCurrency))
        throw new TypeError('Cannot convert to multiple currencies at the same time');

    let instance = new ExchangeRates();

    if (date === 'latest') {
        instance.latest();
    } else {
        instance.at(date);
    }

    return instance.base(fromCurrency).symbols(toCurrency).fetch().then(rate => rate * amount);
};

/**
 * Return a new instance of `ExchangeRates`
 *
 * @return {ExchangeRates}      A new instance of `ExchangeRates`
 */
const exchangeRates = () => new ExchangeRates();

module.exports = { exchangeRates, currencies, convert };