Manual Reference Source Test

src/Client.js

import Clinical6Error from './utilities/Clinical6Error';
import Device from './helpers/Device';
import User from './helpers/user/User';
import LocationService from './services/LocationService';
import StorageUtility from './utilities/StorageUtility';

// http://stackoverflow.com/questions/26205565/converting-singleton-js-objects-to-use-es6-classes
/** @type {Symbol} */
const clientSingleton = Symbol('clientSingleton');
/** @type {Symbol} */
const clientSingletonEnforcer = Symbol('clientSingletonEnforcer');

/**
 * Client is a static class that is used for setting system wide information about
 * connection, device, and general SDK settings.
 */
class Client {
  /**
   * Constructor
   *
   * @param {Symbol} enforcer
   */
  constructor(enforcer) {
    /** @type {Object} */
    this._config = {
      apiBaseUrl: undefined,
      authToken: undefined,
      mobileApplicationKey: undefined,
      // cached: 'always', // or 'never'
      cacheMode: 'networkFirst',
      track: {
        flows: true,
        gps: false
      }
    };
    /** @type {any[]} */
    this._activeRequests = [];
    this.storageUtility = StorageUtility.instance;
    this.reset();

    if (enforcer !== clientSingletonEnforcer) {
      throw new Error('Cannot construct singleton');
    }
  }

  /**
   * Get the instance of the client
   *
   * @type {Object} - instance
   */
  static get instance() {
    if (!this[clientSingleton]) {
      /** @type {Client} */
      this[clientSingleton] = new Client(clientSingletonEnforcer);
    }
    return this[clientSingleton];
  }

  /**
   * Gets the apiBaseUrl
   *
   * @type {String} - Current apiBaseUrl
   */
  get apiBaseUrl() {
    return this.config.apiBaseUrl;
  }

  /**
   * Sets the apiBaseUrl for passing on to following request
   *
   * @param {String} url - Authorization for the API. Retrieved from sign_in_guest.
   * @type  {String}
   */
  set apiBaseUrl(url) {
    let data = url;
    if (typeof data !== 'string') {
      throw new Error('Clinical6 error : apiBaseUrl must be a string');
    }
    if (data[data.length - 1] === '/') {
      data = data.slice(0, -1);
    }
    this.config.apiBaseUrl = data;
  }

  /**
   * Gets the authToken
   *
   * @type {String} - Current authToken
   */
  get authToken() {
    return this.config.authToken;
  }

  /**
   * Sets the authToken for passing on to following request
   *
   * @param {String} authToken - Authorization for the API. Retrieved from sign_in_guest.
   * @type {String}
   */
  set authToken(data) {
    this.config.authToken = data;
  }

  /**
   * Gets promise error handler
   *
   * @type {Function} - Current authToken
   */
  get onError() {
    return this.config.onError;
  }

  /**
   * Sets promise error handler
   *
   * @param {Function} reject - Authorization for the API. Retrieved from sign_in_guest.
   */
  set onError(reject) {
    this.config.onError = reject;
  }

  /**
   * Gets the configuration information
   *
   * @type {Object} - the information in a key value object
   * @property {String} config.apiBaseUrl - url
   * @property {String} config.authToken - some random string
   * @property {String} config.mobileApplicationKey - mobile application key
   * @property {Object} config.track - object to trigger Analytics tracking
   * @property {Boolean} config.track.flows - Log flows to Analytics
   * @property {Boolean} config.track.gps - Track GPS
   */
  get config() {
    return this._config;
  }

  /**
   * Sets the configuration for Clinical6
   *
   * @param {Object} data - the information in a key value object
   * @type {Object}
   * @property {String} config.apiBaseUrl - url
   * @property {String} config.authToken - some random string
   * @property {String} config.mobileApplicationKey - mobile application key
   * @property {Object} config.track - object to trigger Analytics tracking
   * @property {Boolean} config.track.flows - Log flows to Analytics
   * @property {Boolean} config.track.gps - Track GPS
   */
  set config(data) {
    Object.assign(this._config, data);
  }

  /**
   * Gets the device for Clinical6, includes strings technology,
   * version, uuid, and pushId
   *
   * @type {Device} - device information
   */
  get device() {
    return this._device;
  }

  /**
   * Sets the device for Clinical6, includes strings technology,
   * version, uuid, and pushId
   *
   * @param {Device} device - device information
   * @type {Device} - device information
   */
  set device(device) {
    /** @type {Device} */
    this._device = device;
  }

  /**
   * Gets the mobileApplicationKey
   *
   * @type {String} - Current mobileApplicationKey
   */
  get mobileApplicationKey() {
    return this.config.mobileApplicationKey;
  }

  /**
   * Sets the mobileApplicationKey for passing on to following request
   *
   * @param {String} mobileApplicationKey - Authorization for the API. Retrieved before development.
   * @type {String}
   */
  set mobileApplicationKey(mobileApplicationKey) {
    this.config.mobileApplicationKey = mobileApplicationKey;
  }

  /**
   * @type {StorageUtility} - An instance of the storage
   */
  get storageUtility() {
    return this._storageUtility;
  }

  /**
   * Provides a way to override the way that Storage is storing
   *
   * @param {StorageUtility} storageUtility - An instance of the storage
   * @type  {StorageUtility}                - An instance of the storage
   */
  set storageUtility(storageUtility) {
    /** @type {StorageUtility} */
    this._storageUtility = storageUtility;
  }

  /**
   * Set settings
   *
   * @param {Object} settings - Settings
   * @type {Object}
   */
  set settings(settings) {
    this._config.apiBaseUrl = settings._config.apiBaseUrl;
    this.config.authToken = settings._config.authToken;
  }

  /**
   * Gets the user
   *
   * @type {User} - Current user
   */
  get user() {
    return this._user;
  }

  /**
   * Sets the user for passing on to following request
   *
   * @param {User} user - User authenticated on
   * @type {User}
   */
  set user(user) {
    /** @type {User} */
    this._user = user;
  }

  /**
   * Aborts all or filtered active XMLHttpRequests from clinical6
   *
   * @param {Function} filter - Allow developers to filter which ones to abort
   *
   * @example
   * import { clinical6 } from 'clinical6';
   * clinical6.Client.abort();
   * clinical6.Client.abort(r => r.url === '/v3/badges' && r.method === 'PATCH'); // stop updating badges
   */
  abort(filter = () => true) {
    this._activeRequests.filter(filter).forEach(r => r.request.abort());
  }

  /**
   * Prepare and Fetch HTTP request to API
   *
   * @param {!String} url       - Path to the endpoint starting with '/'
   * @param {?String} [method]  - HTTP Method (DELETE|GET|POST|PUT)
   * @param {?Object} [data]    - JSON payload, required on POST and PUT calls.
   * @param {?Object} [headers] - Key/Value list of headers.  Defaults to `{
   *                                Accept: 'application/json',
   *                                'Content-Type': 'application/json'
   *                              }`
   * @return {Promise}          - Resolves on HTTP 200. Rejects on all else.
   */
  fetch(url, method = 'get', data = undefined, headers = {}) {
    const json = { url, method };
    if (data) {
      json.data = data;
    }
    if (headers) {
      json.headers = headers;
    }
    // return Client.instance.http(json);
    const self = this;
    return Client.instance.http(json).then((d) => {
      if (self.config.track.gps && url.indexOf('update_location') === -1 && url.indexOf('/v3/locations') === -1) {
        if (self._insertLocationTimeout) {
          clearTimeout(self._insertLocationTimeout);
          delete self._insertLocationTimeout;
        }
        self._insertLocationTimeout = setTimeout(() => {
          if (self.authToken) {
            (new LocationService()).insertCurrent();
          }
        }, 500);
      }
      return d;
    });
  }

  /**
   * Prepare and Fetch HTTP request to API with more customization
   *
   * @param   {Object}  json            - Object containing connection information
   * @param   {!String} json.url        - Path to the endpoint starting with '/'
   * @param   {?String} [json.method]   - HTTP Method (DELETE|GET|POST|PUT)
   * @param   {?Object} [json.data]     - JSON payload, required on POST and PUT calls.
   * @param   {?Object} [json.headers]  - Key/Value list of headers.  Defaults to `{
   *                                        Accept: 'application/json',
   *                                        'Content-Type': 'application/json'
   *                                      }`
   * @return  {Promise}                 - Resolves on HTTP 200. Rejects on all else.
   */
  http(json) {
    // Add default values
    const _json = Object.assign({}, { method: 'get' }, json);

    // Add default headers
    _json.headers = {
      Accept: 'application/json',
      'Content-Type': 'application/json'
    };

    // Add Authorization only if authenticated (token exists)
    switch (this.user.type) {
      case 'users':
      case 'user_sessions':
        if (this.config.authToken) {
          _json.headers['X-User-Token'] = this.config.authToken;
        }

        // Populate with account name or use email if it exists
        if (this.user.username) {
          _json.headers['X-User-Username'] = this.user.username;
        } else if (this.user.email) {
          _json.headers['X-User-Username'] = this.user.email;
        }
        break;

      case 'mobile_users':
      default:
        if (this.config.authToken) {
          _json.headers.Authorization = `Token token=${this.config.authToken}`;
        }
        break;
    }

    // Add custom headers
    _json.headers = Object.assign({}, _json.headers, json.headers);

    // Determine if method is valid
    if ((['POST', 'PUT', 'PATCH'].indexOf(_json.method.toUpperCase()) !== -1) && !_json.data) {
      throw new Error('Clinical6 fetch error: invalid PUT/POST request, no data given');
    }

    // Execute async call to the server
    return new Promise((resolve, reject) => {
      const request = new XMLHttpRequest();
      
      // Simply reject on error
      request.onerror = (err) => {
        this._activeRequests.splice(this._activeRequests.indexOf(_json), 1);
        reject(err);
      };

      request.onabort = () => {
        this._activeRequests.splice(this._activeRequests.indexOf(_json), 1);
        reject();
      };

      // On response, get valid HTTP code
      request.onload = () => {
        this._activeRequests.splice(this._activeRequests.indexOf(_json), 1);
        const response = (request.response) ? JSON.parse(request.response) : undefined;
        if (request.status >= 200 && request.status < 300) {
          return resolve(response);
        }
        if (Client.instance.onError) {
          Client.instance.onError(request.status, new Clinical6Error(response, undefined, request));
        }
        return reject(new Clinical6Error(response, undefined, request));
      };

      // open http connection to destination
      request.open(_json.method.toUpperCase(), `${this.config.apiBaseUrl}${_json.url}`, true);

      // Setup headers - request must be open first
      Object.keys(_json.headers).forEach(p => request.setRequestHeader(p, _json.headers[p]));

      // Convert the payload to JSON & send
      request.send(JSON.stringify(_json.data) || null);
      _json.request = request;
      this._activeRequests.push(_json);
    });
  }

  /**
   * Fetches from either store or from the network
   *
   * @param {String}        cacheKey          - The data type or key for cache / storage
   * @param {Promise<any>}  fetchPromise      - The promise expecting a fetch call
   * @param {Object}        options           - Expected options
   * @param {Number}        [options.mode]    - The cache mode (e.g. 'networkFirst').
   *                                            Defaults to whatever is in Client.cacheMode.
   * @param {Number}        [options.id]      - The id for a specific item
   * @param {Function}      [options.resolve] - Function that resolves the data to the
   *                                            calling application.  Default will use data
   *                                            directly from fetch response.
   * @param {Function}      [options.store]   - Function that returns data to be stored.
   *                                            Default will use data directly from fetch
   *                                            response.
   * @param {Function}      [options.key]     - The key used for the hash id in storage.
   * @return {Promise<any>}                   - Promise indicating success or failure
   */
  request(cacheKey, fetchPromise, options = {}) {
    // const optionsDefault = { asArray: true };
    // options = Object.assign(optionsDefault, options);
    const cacheMode = options.mode || this.config.cacheMode;
    let _return = new Promise(resolve => resolve()); // Default return
    if (cacheMode === 'cacheFirst' || cacheMode === 'cacheOnly') {
      // Return cached information if cacheFirst or cacheOnly
      const _self = this;
      _return = this.storageUtility.get(cacheKey, options).then((data) => {
        if (!data || (typeof data === 'object' && Object.keys(data).length === 0)) {
          options.mode = 'networkOnly';
          return _self.request(cacheKey, fetchPromise, options);
        }
        return new Promise(resolve => resolve(data));
      });
    } else if (cacheMode !== 'cacheOnly') {
      _return = new Promise((resolve, reject) => {
        fetchPromise.then((response) => {
          // Allow for custom storing (if necessary)
          const storeData = (options.store) ? options.store(response) : response;
          if (storeData) {
            // This is required for insert, sometimes the data coming back has an id but you don't
            // know it from options.id (on insert)
            if (storeData.id) { options.id = storeData.id; }
            if (options.clear) {
              this.storageUtility.clear(cacheKey, options.id);
            } else {
              this.storageUtility.set(cacheKey, storeData, options);
            }
          }

          // Allow for custom resolve function (if necessary)
          if (options.resolve) { options.resolve(response, resolve); } else { resolve(response); }
        }, (fetchError) => {
          if (fetchError.currentTarget && (fetchError.currentTarget.status === 0) && cacheMode !== 'networkOnly') {
            // On failure and not requiring networkOnly
            this.storageUtility.get(cacheKey, options).then(
              (data) => {
                // Check to see that there's actual data from a previous call, if not resolve the original fetchError
                if (data !== undefined && data !== null && Object.keys(data).length > 0) {
                  resolve(data);
                }
                resolve(fetchError);
              },
              storageError => reject(storageError)
            ).catch(storageError => reject(storageError));
          } else {
            reject(fetchError);
          }
        }).catch(fetchError => reject(fetchError));
      });
    }
    return _return;
  }

  /**
   * Resets user data and Authentication information
   */
  reset() {
    this.authToken = undefined;
    this.device = new Device({ push_id: 'FAKE_ID' });
    let type;
    if (this.user && this.user.type) {
      ({ type } = this.user);
    }
    this.user = new User({ type, guest: true });
    // @TODO We need to do a more granular removal of user information using client.storageUtility.clear()
    this.storageUtility.clear();
  }
}

export default Client;