collection/query-param.js

var _ = require('../util').lodash,

    Property = require('./property').Property,
    PropertyList = require('./property-list').PropertyList,

    E = '',
    AMPERSAND = '&',
    STRING = 'string',
    EQUALS = '=',
    EMPTY = '',
    HASH = '#',
    REGEX_HASH = /#/g,
    REGEX_EQUALS = /=/g, // eslint-disable-line no-div-regex
    REGEX_AMPERSAND = /&/g,
    REGEX_EXTRACT_VARS = /{{[^{}]*[&#=][^{}]*}}/g,

    QueryParam,

    /**
     * Percent encode reserved chars (&, = and #) in the given string.
     *
     * @private
     * @param {String} str -
     * @param {Boolean} encodeEquals -
     * @returns {String}
     */
    encodeReservedChars = function (str, encodeEquals) {
        if (!str) {
            return str;
        }

        // eslint-disable-next-line lodash/prefer-includes
        str.indexOf(AMPERSAND) !== -1 && (str = str.replace(REGEX_AMPERSAND, '%26'));

        // eslint-disable-next-line lodash/prefer-includes
        str.indexOf(HASH) !== -1 && (str = str.replace(REGEX_HASH, '%23'));

        // eslint-disable-next-line lodash/prefer-includes
        encodeEquals && str.indexOf(EQUALS) !== -1 && (str = str.replace(REGEX_EQUALS, '%3D'));

        return str;
    },

    /**
     * Normalize the given param string by percent-encoding the reserved chars
     * such that it won't affect the re-parsing.
     *
     * @note `&`, `=` and `#` needs to be percent-encoded otherwise re-parsing
     * the same URL string will generate different output
     *
     * @private
     * @param {String} str -
     * @param {Boolean} encodeEquals -
     * @returns {String}
     */
    normalizeParam = function (str, encodeEquals) {
        // bail out if the given sting is null or empty
        if (!(str && typeof str === STRING)) {
            return str;
        }

        // bail out if the given string does not include reserved chars
        // eslint-disable-next-line lodash/prefer-includes
        if (str.indexOf(AMPERSAND) === -1 && str.indexOf(HASH) === -1) {
            // eslint-disable-next-line lodash/prefer-includes
            if (!(encodeEquals && str.indexOf(EQUALS) !== -1)) {
                return str;
            }
        }

        var normalizedString = '',
            pointer = 0,
            variable,
            match,
            index;

        // find all the instances of {{<variable>}} which includes reserved chars
        while ((match = REGEX_EXTRACT_VARS.exec(str)) !== null) {
            variable = match[0];
            index = match.index;

            // [pointer, index) string is normalized + the matched variable
            normalizedString += encodeReservedChars(str.slice(pointer, index), encodeEquals) + variable;

            // update the pointer
            pointer = index + variable.length;
        }

        // whatever left in the string is normalized as well
        if (pointer < str.length) {
            normalizedString += encodeReservedChars(str.slice(pointer), encodeEquals);
        }

        return normalizedString;
    };

/**
 * @typedef QueryParam.definition
 * @property {String} key The name ("key") of the query parameter.
 * @property {String} value The value of the parameter.
 */
_.inherit((

    /**
     * Represents a URL query parameter, which can exist in request URL or POST data.
     *
     * @constructor
     * @extends {Property}
     * @param {FormParam.definition|String} options Pass the initial definition of the query parameter. In case of
     * string, the query parameter is parsed using {@link QueryParam.parseSingle}.
     */
    QueryParam = function PostmanQueryParam (options) {
        // this constructor is intended to inherit and as such the super constructor is required to be executed
        QueryParam.super_.apply(this, arguments);

        this.update(options);
    }), Property);

_.assign(QueryParam.prototype, /** @lends QueryParam.prototype */ {
    /**
     * Converts the QueryParameter to a single param string.
     *
     * @returns {String}
     */
    toString () {
        return QueryParam.unparseSingle(this);
    },

    /**
     * Updates the key and value of the query parameter
     *
     * @param {String|Object} param -
     * @param {String} param.key -
     * @param {String=} [param.value] -
     */
    update (param) {
        _.assign(this, /** @lends QueryParam.prototype */ _.isString(param) ? QueryParam.parseSingle(param) : {
            key: _.get(param, 'key'), // we do not replace falsey with blank string since null has a meaning
            value: _.get(param, 'value')
        });
        _.has(param, 'system') && (this.system = param.system);
    },

    valueOf () {
        return _.isString(this.value) ? this.value : EMPTY;
    }
});

_.assign(QueryParam, /** @lends QueryParam */ {

    /**
     * Defines the name of this property for internal use.
     *
     * @private
     * @readOnly
     * @type {String}
     */
    _postman_propertyName: 'QueryParam',

    /**
     * Declare the list index key, so that property lists of query parameters work correctly
     *
     * @type {String}
     */
    _postman_propertyIndexKey: 'key',

    /**
     * Query params can have multiple values, so set this to true.
     *
     * @type {Boolean}
     */
    _postman_propertyAllowsMultipleValues: true,

    /**
     * Parse a query string into an array of objects, where each object contains a key and a value.
     *
     * @param {String} query -
     * @returns {Array}
     */
    parse: function (query) {
        return _.isString(query) ? query.split(AMPERSAND).map(QueryParam.parseSingle) : [];
    },

    /**
     * Parses a single query parameter.
     *
     * @param {String} param -
     * @param {Number} idx -
     * @param {String[]} all - array of all params, in case this is being called while parsing multiple params.
     * @returns {{key: String|null, value: String|null}}
     */
    parseSingle: function (param, idx, all) {
        // helps handle weird edge cases such as "/get?a=b&&"
        if (param === EMPTY && // if param is empty
            _.isNumber(idx) && // this and the next condition ensures that this is part of a map call
            _.isArray(all) &&
            idx !== (all && (all.length - 1))) { // not last parameter in the array
            return { key: null, value: null };
        }

        var index = (typeof param === STRING) ? param.indexOf(EQUALS) : -1,
            paramObj = {};

        // this means that there was no value for this key (not even blank, so we store this info) and the value is set
        // to null
        if (index < 0) {
            paramObj.key = param.substr(0, param.length);
            paramObj.value = null;
        }
        else {
            paramObj.key = param.substr(0, index);
            paramObj.value = param.substr(index + 1);
        }

        return paramObj;
    },

    /**
     * Create a query string from array of parameters (or object of key-values).
     *
     * @note Disabled parameters are excluded.
     *
     * @param {Array|Object} params -
     * @returns {String}
     */
    unparse: function (params) {
        if (!params) { return EMPTY; }

        var str,
            firstEnabledParam = true;

        // Convert hash maps to an array of params
        if (!_.isArray(params) && !PropertyList.isPropertyList(params)) {
            return _.reduce(params, function (result, value, key) {
                result && (result += AMPERSAND);

                return result + QueryParam.unparseSingle({ key, value });
            }, EMPTY);
        }

        // construct a query parameter string from the list, with considerations for disabled values
        str = params.reduce(function (result, param) {
            // bail out if param is disabled
            if (param.disabled === true) { return result; }

            // don't add '&' for the very first enabled param
            if (firstEnabledParam) {
                firstEnabledParam = false;
            }
            // add '&' before concatenating param
            else {
                result += AMPERSAND;
            }

            return result + QueryParam.unparseSingle(param);
        }, EMPTY);

        return str;
    },

    /**
     * Takes a query param and converts to string
     *
     * @param {Object} obj -
     * @returns {String}
     */
    unparseSingle: function (obj) {
        if (!obj) { return EMPTY; }

        var key = obj.key,
            value = obj.value,
            result;

        if (typeof key === STRING) {
            result = normalizeParam(key, true);
        }
        else {
            result = E;
        }

        if (typeof value === STRING) {
            result += EQUALS + normalizeParam(value);
        }

        return result;
    }
});

module.exports = {
    QueryParam
};