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;