Source: client/api.js

/**
 * @file Volt Core Library - api method
 *
 * @license
 * (c) 2017 NS BASIC Corporation. All rights reserved.
 */

// TODO: consider if it's worthwhile to somehow document all the
// possible data and error objects that this callback could spawn

// TODO: make some custom error objects - the errors below would be
// more easily handled if we returned different types of errors for
// each error state

/**
 * Volt general callback - used by all asynchronous core methods
 *
 * In cases where the callback is omitted, a promise is returned from
 * the method instead. The promise is resolved with `data` on success.
 * On error, it's rejected with `error`. In cases where the error has
 * data available, it can be found in `error.data`.
 *
 * @callback voltCallback
 * @param {Error|null} error - an Error object, or null if no error occurred
 * @param [data] - the data from an asynchronous request, or undefined if there is no data
 */

$volt.register('api', function (core, state) {
  'use strict';

  var timeout = 30 * 1000;  // the request timeout, in ms
  var base = '/api';

  if (state.domain) {
    base = 'https://' + state.domain + base;
  }

  function interpolate(str, params) {
    var key, match;

    for (key in params) {
      if (params.hasOwnProperty(key)) {
        str = str.replace(new RegExp('\\{\\{' + key + '\\}\\}', 'g'),
                          params[key]);
      }
    }

    // check for uninterpolated params in str
    match = /\{\{(.+?)\}\}/.exec(str);
    if (match) {
      throw new Error('Key error: ' + match[1]);
    }

    return str;
  }

  /**
   * Make an API request
   *
   * The `url` parameter (see below) can contain a simplified template
   * that is interpolated from the `params` parameter. For example:
   *
   *     url = 'https://example.com/user/{{userId}}';
   *     params = { userId: 'Uyh67d' };
   *
   * @function api
   * @private
   * @memberOf $volt
   *
   * @param {string} method - the method of the API call (e.g. 'GET', 'POST', etc...)
   * @param {string} url - the URL of the API endpoint (can contain references to params to be interpolated)
   * @param {object} params - the params to be interpolated into the URL (or an empty object if there are none)
   * @param {object} body - the object to be JSON encoded and passed as the body of the request, a [FormData](https://developer.mozilla.org/en-US/docs/Web/API/FormData) object, or undefined if no body is required
   * @param {voltCallback} [callback] - called once the API request either fails or completes - if not passed, a promise is returned
   */
  return function (method, url, params, body, callback) {
    /* jshint maxstatements: 18 */
    var xhr = new XMLHttpRequest();

    callback = core.methodAsPromised(callback);

    url = base + url;
    url = interpolate(url, params);

    // cheap hack to disable GET caching
    url += url.indexOf('?') > -1 ? '&' : '?';
    url += '_=' + (new Date()).getTime().toString();

    // IE11 wants the XHR open before configuring it
    xhr.open(method, url);

    xhr.timeout = timeout;  // should the user be able to override?
    xhr.addEventListener('timeout', function () {
      callback(new Error('A network timeout occurred.'));
    });

    xhr.addEventListener('error', function () {
      callback(new Error('A network error occurred.'));
    });

    xhr.addEventListener('load', function () {
      var contentType = xhr.getResponseHeader('Content-Type') || '';
      var expected = 'application/json';
      var error = null, data;

      if (contentType.substr(0, expected.length) === expected) {
        // this could fail - but if it does, it means something has
        // gone terribly wrong - allow the exception to be thrown
        data = JSON.parse(xhr.responseText);
      } else {
        data = xhr.responseText;
      }

      if (xhr.status >= 400) {
        error = new Error('An API error occurred.');
        error.status = xhr.status;

        if (xhr.status === 401) {
          // bad or no authentication, clear the state
          state.clear();
        }
      }

      callback(error, data);
    });

    // add the bearer token if needed
    if (state.accessToken) {
      xhr.setRequestHeader('Authorization', 'Bearer ' + state.accessToken);
    }

    if (typeof body !== 'undefined' && !(body instanceof FormData)) {
      // there's probably a cleaner way to do this...
      xhr.setRequestHeader('Content-Type', 'application/json');
      body = JSON.stringify(body);
    }

    // send the request (an undefined body is ignored)
    xhr.send(body);

    return callback.promise;
  };
});