Source: lib/utils.js

/**
 * @module utils
 * @description
 * SDK Utilities, used internally.
 */

"use strict";

// module exports
exports = module.exports = {
    addQueries: addQueries,
    allowOptionalParams: allowOptionalParams,
    collectPages: collectPages,
    getOptions: getOptions,
    getAuthOptions: getAuthOptions,
    getURIOptions: getURIOptions,
    createTimestamp: createTimestamp,
    getPollerOptions: getPollerOptions,
    getProxy: getProxy,
    parseResponse: parseResponse,
    passResponse: passResponse,
    pickParams: pickParams,
    request: request,
    removeAuthOptions: removeAuthOptions,
    removeOptions: removeOptions,
    removeURIOptions: removeURIOptions,
    setProxy: setProxy,
    setup: setup,
    shuffleArray: shuffleArray,
    sortKeys: sortKeys,
    url: url,
};

// npm-installed modules
var _ = require("lodash");
var decamelize = require("decamelize");
var errors = require("common-errors");
var rawRequest = require("request");
var URI = require("urijs");

// own modules
var config = require("../config");

// module variables
var SETTINGS = _.cloneDeep(config);
var uriKeys = ["proxy", "apiVersion"];
var authKeys = ["key", "secret"];
var pollerKeys = ["interval"];

/**
 * Return a custom request object
 *
 * @public
 *
 * @return {Object} request object
 */
function request() {
    return rawRequest.defaults(SETTINGS.request);
}

/**
 * Set the SDK settings
 *
 * @public
 *
 * @param {SETTINGS} settings - new SDK settings
 * @return {Object} the newly-set SDK settings
 */
function setup(settings) {
    if (!settings) {
        return _.cloneDeep(SETTINGS);
    }
    _.merge(SETTINGS, settings);
    return _.cloneDeep(SETTINGS);
}

/**
 * Setting up proxy for the whole SDK
 *
 * @public
 *
 * @param {String} proxy - a valid url
 */
function setProxy(proxy) {
    SETTINGS.proxy = proxy;
    return SETTINGS.proxy;
}

/**
 * Return the currently set proxy
 *
 * @public
 *
 * @return {String} the set proxy url
 */
function getProxy() {
    return SETTINGS.proxy;
}

/**
 * Return an URI object (from URI.js) for easier URL building.
 *
 * @public
 * @see http://medialize.github.io/URI.js/docs.html for docs on URI.js
 *
 * @param {String} [label] label - identifying the URL.
 * @param {Object} [options] options - options to be used
 * @param {Object} [ctx] ctx - context used for path param interpolation
 * @return {URI} an instance of URI.js
 */
function url(label, options, ctx) {
    switch (label) {
        case "activations":
            return url(options).segment("activations");
        case "advertLocations":
            return url(options).segment("adlocations");
        case "bannerAdverts":
            return url(options).segment("bannerads");
        case "campaigns":
            return url(options).segment("campaigns");
        case "contactUs":
            return url(options).segment("contactus");
        case "countries":
            return url(options).segment("countries");
        case "directions":
            return url(options).segment("directions");
        case "drivingReports":
            return url(options).segment("drivingreports");
        case "driveTypes":
            return url(options).segment("drivetypes");
        case "externalStream":
            return url(options).segment("externalstream");
        case "listedAdverts":
            return url(options).segment("ads");
        case "news":
            return url(options).segment("news");
        case "places":
            return url(options).segment("places");
        case "severity":
            return url(options).segment("severities");
        case "sseDrivingReports":
            return url(options).segment("ssedrivingreports");
        case "sseTrafficUpdates":
            return url(options).segment("ssetrafficupdates");
        case "towns":
            return url(options).segment("towns");
        case "trafficUpdates":
            return url(options).segment("trafficupdates");
        case "users":
            return url(options).segment("users");
        default:
            options = options || {};
            ctx = ctx || {};
            if (label && typeof label === "object") {
                options = label;
                label = null;
            }
            // Proxy URLs always override the default ones
            var topLevel =
                options.proxy ||
                SETTINGS.proxy ||
                (options.apiVersion === 3
                    ? SETTINGS.baseurlv3
                    : SETTINGS.baseurl);
            var uri = URI(topLevel);
            if (label) {
                var match = /:(\w+)/.exec(label);
                if (match && ctx[match[1]]) {
                    label = label.replace(match[0], ctx[match[1]]);
                    delete ctx[match[1]];
                }
                return uri.segment(label);
            }
            return uri;
    }
}

/**
 * Shuffles items in an array
 * Operation takes O(n) time. The array passed is operated on as its
 * reference, rather than value, is passed to the function.
 *
 * @public
 *
 * @param {Array} array - an array of items
 * @return {Array} array with the the items shuffled
 */
function shuffleArray(array) {
    var length = array.length;
    var randomIndex;
    var currentIndex = 0;
    var tmp;
    for (; currentIndex < length; currentIndex++) {
        randomIndex = Math.floor(Math.random() * length);
        tmp = array[currentIndex];
        array[currentIndex] = array[randomIndex];
        array[randomIndex] = tmp;
    }
    return array;
}

/**
 * Generate a UNIX timestamp
 *
 * @public
 *
 * @return {Number} UNIX timestamp
 */
function createTimestamp() {
    return Date.now();
}

/**
 * Return a sorted array of an object's keys
 *
 * @public
 *
 * @param {Object} object - an object
 * @return {Array} array of sorted keys
 */
function sortKeys(object) {
    var keys = _.keys(object);
    return keys.sort();
}

/**
 * Add properties to response
 *
 * @private
 * @see {@link http://visionmedia.github.io/superagent/#response-properties}
 *
 * @param {Object} response - as from the `request` module
 * @return {Object} the originally passed but modified reponse
 */
function parseResponse(response) {
    var code = response.statusCode;
    var type = (code / 100) | 0;
    response.type = type;

    // basics
    switch (type) {
        case 1:
            response.info = type;
            break;
        case 2:
            response.ok = type;
            break;
        case 3:
            // ???
            break;
        case 4:
            response.clientError = type;
            response.error = type;
            response.ErrorClass = errors.HttpStatusError;
            break;
        case 5:
            response.serverError = type;
            response.error = type;
            response.ErrorClass = errors.HttpStatusError;
            break;
    }

    // error classes and sugar
    switch (code) {
        case 201:
            response.created = code;
            break;
        case 202:
            response.accepted = code;
            break;
        case 204:
            response.noContent = code;
            break;
        case 400:
            response.badRequest = code;
            break;
        case 401:
            response.unauthorized = code;
            response.ErrorClass = errors.AuthenticationRequiredError;
            break;
        case 403:
            response.forbidden = code;
            response.ErrorClass = errors.NotPermittedError;
            break;
        case 404:
            response.notFound = code;
            response.ErrorClass = errors.NotFoundError;
            break;
        case 406:
            response.notAcceptable = code;
            break;
        case 500:
            response.internalServerError = code;
            break;
    }

    // using body (and not status code)
    if (response.body && response.body.success === false) {
        if (!response.error) {
            response.error = 4; // assume is client error
            response.ErrorClass = errors.HttpStatusError;
        }
    }

    // null bodies
    if (_.includes([null, undefined, ""], response.body)) {
        if (!response.error) {
            response.error = 5; // assume the server fucked up
            response.ErrorClass = errors.HttpStatusError;
        }
    }

    return response;
}

/**
 * Parse response then pass to callback
 *
 * @public
 *
 * @param {Function} callback - user's callback
 * @param {Object} [options]
 * @param {Boolean} [options.post] - callback is for a POST request
 * @param {Boolean} [options.put] - callback is for a PUT request
 * @return {Function} function to handle responses from `request` calls
 */
function passResponse(callback, options) {
    options = options || {};
    return function handleResponse(error, response, body) {
        if (error) {
            return callback(new errors.io.IOError(error.mesage, error));
        }
        response = parseResponse(response);
        body = body || {};
        if (response.error) {
            var message = response.body
                ? response.body.message
                : "received an empty body";
            var err = new response.ErrorClass(response.statusCode, message);
            if (options.post || options.put)
                return callback(err, body, response);
            return callback(err, body.data, body.meta, response);
        }
        if (options.post || options.put) return callback(null, body, response);
        return callback(null, body.data, body.meta, response);
    };
}

/**
 * Append querystring using key-value pairs from an object.
 * If a key exists both in the uri object and params, the one
 * in the uri object will be replaced. Any camelcased key is
 * automatically converted to lowercase with underscore separators.
 * Some keys can be excluded automatically. They include: secret.
 *
 * @public
 *
 * @param {URI} uri - an instance of URIjs
 * @param {Object} params - object with parameters
 * @param {Boolean} [toExclude=true] - whether to exclude keys automatically
 * @return {URIjs} the uri with the queries appended
 */
function addQueries(uri, params, toExclude) {
    toExclude = _.isBoolean(toExclude) ? toExclude : true;
    var excludes = ["key", "secret", "proxy"];
    for (var key in params) {
        // ignore excludes
        if (toExclude && _.includes(excludes, key)) {
            continue;
        }
        if (params.hasOwnProperty(key)) {
            // remove key if it exists
            uri.removeQuery(key);
            uri.addQuery(key, params[key]);
        }
    }
    return uri;
}

/**
 * Allow 'params' to be optional, thus user can pass 'callback' in its place.
 *
 * @public
 *
 * @param {*} params - user's params
 * @param {Function} [callback] - user's callback
 * @return {Object} args
 * @return {Function} args.callback
 * @return {Function} args.params
 */
function allowOptionalParams(params, callback) {
    if (!callback) {
        callback = params;
        params = {};
    }
    return {
        params: params,
        callback: callback,
    };
}

/**
 * Return an object with key-value pairs, using keys, from an object
 *
 * @param {Object|Object[]} sources - object(s) to look for the properties
 * @param {Array} keys - an array of keys of the properties to look up
 * @param {Object} [dest] - destination object
 * @return {Object} options
 */
function getOptions(sources, keys, dest) {
    var options = dest || {};

    // allow source be an array
    if (!_.isArray(sources)) {
        sources = [sources];
    }

    // using the keys for lookup
    keys.forEach(function(key) {
        // from each source
        sources.forEach(function(source) {
            if (source && source[key]) {
                options[key] = source[key];
            }
        });
    });

    return options;
}

/**
 * Return an object with options required to instantiate URI.
 *
 * @param {Object|Object[]} sources - objects to look for the properties
 * @param {Object} [dest] - destination object
 * @return {Object} options
 */
function getURIOptions(sources, dest) {
    return getOptions(sources, uriKeys, dest);
}

/**
 * Delete keys from objects
 *
 * @param {Object[]} args
 * @param {String|String[]} keys
 */
function removeOptions(args, keys) {
    if (!_.isArray(keys)) {
        keys = [keys];
    }
    for (var index = 0; index < args.length; index++) {
        var arg = args[index];
        for (var key in arg) {
            if (_.includes(keys, key)) {
                delete arg[key];
            }
        }
    }
}

/**
 * Remove all options applicable for creating a new URI instance
 *
 * @param {...Object} objects - objects to modify
 */
function removeURIOptions() {
    return removeOptions(arguments, uriKeys);
}

/**
 * Remove all options applicable for signing requests
 *
 * @param {...Object} objects - objects to modify
 */
function removeAuthOptions() {
    return removeOptions(arguments, authKeys);
}

/**
 * Return an object with authentication options from an object
 *
 * @param {Object|Object[]} sources - objects to look for the properties
 * @param {Object} [dest] - destination object
 * @return {Object} options
 */
function getAuthOptions(sources, dest) {
    return getOptions(sources, authKeys, dest);
}

/**
 * Return an object with poller options from sources
 *
 * @param  {Object|Object[]} sources
 * @param  {Object} [dest]
 * @return {Object} options
 */
function getPollerOptions(sources, dest) {
    return getOptions(sources, pollerKeys, dest);
}

/**
 * Pick out parameters
 *
 * @param  {Object} params
 * @return {Object} cleaner params
 */
function pickParams(params, keys, camelcase) {
    camelcase = camelcase === undefined ? true : camelcase;
    params = _.cloneDeep(params);
    // decamelize all options
    for (var key in params) {
        var value = params[key];
        delete params[key];
        // TODO: handle this inconsistency consistently in the library :(
        // 'base64String': introduced by 'image.upload()'
        if (["base64String"].indexOf(key) === -1 && camelcase) {
            key = decamelize(key, "_");
        }
        params[key] = value;
    }
    if (SETTINGS.enforce_params_filter) {
        return _.pick(params, keys);
    }
    return params;
}

/**
 * Helper function for collecting all pages of items.
 *
 * @param  {Function} func(lastReadId, next) Function invoked to fetch requests
 * @param  {Object} callback(error, items) Function invoked once all pages have been fetched
 */
function collectPages(func, callback) {
    var items = [];
    var lastReadId = 0;
    function __collect() {
        return func(lastReadId, function(error, page) {
            if (error) {
                return callback(error);
            }
            if (!page.length) {
                return callback(null, items);
            }
            items = items.concat(page);
            lastReadId = page[page.length - 1].id;
            return __collect();
        });
    }
    return __collect();
}