664 lines
20 KiB
JavaScript
664 lines
20 KiB
JavaScript
/**
|
|
* @fileoverview
|
|
*
|
|
* Copied the URL parser and unparser from the SDK.
|
|
* @todo Move this into it's own separate module, and make the SDK and transformer depend on it.
|
|
*/
|
|
|
|
const _ = require('lodash').noConflict(),
|
|
|
|
E = '',
|
|
HASH = '#',
|
|
SLASH = '/',
|
|
COLON = ':',
|
|
EQUALS = '=',
|
|
AMPERSAND = '&',
|
|
AUTH_SEPARATOR = '@',
|
|
QUERY_SEPARATOR = '?',
|
|
DOMAIN_SEPARATOR = '.',
|
|
PROTOCOL_SEPARATOR = '://',
|
|
PROTOCOL_SEPARATOR_WITH_BACKSLASH = ':\\\\',
|
|
|
|
STRING = 'string',
|
|
FUNCTION = 'function',
|
|
SAFE_REPLACE_CHAR = '_',
|
|
CLOSING_SQUARE_BRACKET = ']',
|
|
URL_PROPERTIES_ORDER = ['protocol', 'auth', 'host', 'port', 'path', 'query', 'hash'],
|
|
|
|
REGEX_HASH = /#/g,
|
|
REGEX_EQUALS = /=/g, // eslint-disable-line no-div-regex
|
|
REGEX_AMPERSAND = /&/g,
|
|
REGEX_EXTRACT_VARS = /{{[^{}]*[.:/?#@&\]][^{}]*}}/g,
|
|
REGEX_EXTRACT_VARS_IN_PARAM = /{{[^{}]*[&#=][^{}]*}}/g,
|
|
|
|
/**
|
|
* Percent encode reserved chars (&, = and #) in the given string.
|
|
*
|
|
* @private
|
|
* @param {String} str - String to encode
|
|
* @param {Boolean} encodeEquals - Encode '=' if true
|
|
* @returns {String} - Encoded 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 - Parameter string to normalize
|
|
* @param {Boolean} encodeEquals - If true, encode '=' while normalizing
|
|
* @returns {String} - Normalized param 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_IN_PARAM.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;
|
|
},
|
|
|
|
/**
|
|
* Unparses a single query param into a string.
|
|
*
|
|
* @private
|
|
* @param {Object} obj - The query parameter object to be unparsed.
|
|
* @returns {String} - The unparsed query string.
|
|
*/
|
|
unparseQueryParam = function (obj) {
|
|
if (!obj) { return E; }
|
|
|
|
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;
|
|
},
|
|
|
|
/**
|
|
* Parses a single query param string into an object.
|
|
*
|
|
* @private
|
|
* @param {String} param - The query parameter string to be parsed.
|
|
* @returns {Object} - The parsed query object.
|
|
*/
|
|
parseQueryParam = function (param) {
|
|
if (param === E) {
|
|
return { key: null, value: null };
|
|
}
|
|
|
|
var index = _.indexOf(param, EQUALS);
|
|
|
|
if (index < 0) {
|
|
return { key: param, value: null };
|
|
}
|
|
|
|
return {
|
|
key: param.substr(0, index),
|
|
value: param.substr(index + 1)
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Tracks replacements done on a string and expose utility to patch replacements.
|
|
*
|
|
* @note due to performance reasons, it doesn't store the original string or
|
|
* perform ops on the string.
|
|
*
|
|
* @private
|
|
* @constructor
|
|
*/
|
|
function ReplacementTracker () {
|
|
this.replacements = [];
|
|
this._offset = 0;
|
|
this._length = 0;
|
|
}
|
|
|
|
/**
|
|
* Add new replacement to track.
|
|
*
|
|
* @param {String} value - value being replaced
|
|
* @param {Number} index - index of replacement
|
|
*/
|
|
ReplacementTracker.prototype.add = function (value, index) {
|
|
this.replacements.push({
|
|
value: value,
|
|
index: index - this._offset
|
|
});
|
|
|
|
this._offset += value.length - 1; // - 1 replaced character
|
|
this._length++;
|
|
};
|
|
|
|
/**
|
|
* Returns the total number of replacements.
|
|
*
|
|
* @returns {Number}
|
|
*/
|
|
ReplacementTracker.prototype.count = function () {
|
|
return this._length;
|
|
};
|
|
|
|
/**
|
|
* Finds the lower index of replacement position for a given value using inexact
|
|
* binary search.
|
|
*
|
|
* @param {Number} index - index to search in replacements
|
|
* @returns {Number}
|
|
*/
|
|
ReplacementTracker.prototype._findLowerIndex = function (index) {
|
|
var length = this.count(),
|
|
start = 0,
|
|
end = length - 1,
|
|
mid;
|
|
|
|
while (start <= end) {
|
|
mid = (start + end) >> 1; // divide by 2
|
|
|
|
if (this.replacements[mid].index >= index) {
|
|
end = mid - 1;
|
|
}
|
|
else {
|
|
start = mid + 1;
|
|
}
|
|
}
|
|
|
|
return start >= length ? -1 : start;
|
|
};
|
|
|
|
/**
|
|
* Patches a given string by apply all the applicable replacements done in the
|
|
* given range.
|
|
*
|
|
* @param {String} input - string to apply replacements on
|
|
* @param {Number} beginIndex - index from where to apply replacements in input
|
|
* @param {Number} endIndex - index until where to apply replacements in input
|
|
* @returns {String}
|
|
*/
|
|
ReplacementTracker.prototype._applyInString = function (input, beginIndex, endIndex) {
|
|
var index,
|
|
replacement,
|
|
replacementIndex,
|
|
replacementValue,
|
|
offset = 0,
|
|
length = this.count();
|
|
|
|
// bail out if no replacements are done in the given range OR empty string
|
|
if (!input || (index = this._findLowerIndex(beginIndex)) === -1) {
|
|
return input;
|
|
}
|
|
|
|
do {
|
|
replacement = this.replacements[index];
|
|
replacementIndex = replacement.index;
|
|
replacementValue = replacement.value;
|
|
|
|
// bail out if all the replacements are done in the given range
|
|
if (replacementIndex >= endIndex) {
|
|
break;
|
|
}
|
|
|
|
replacementIndex = offset + replacementIndex - beginIndex;
|
|
input = input.slice(0, replacementIndex) + replacementValue + input.slice(replacementIndex + 1);
|
|
offset += replacementValue.length - 1;
|
|
} while (++index < length);
|
|
|
|
return input;
|
|
};
|
|
|
|
/**
|
|
* Patches a given string or array of strings by apply all the applicable
|
|
* replacements done in the given range.
|
|
*
|
|
* @param {String|String[]} input - string or splitted string to apply replacements on
|
|
* @param {Number} beginIndex - index from where to apply replacements in input
|
|
* @param {Number} endIndex - index until where to apply replacements in input
|
|
* @returns {String|String[]}
|
|
*/
|
|
ReplacementTracker.prototype.apply = function (input, beginIndex, endIndex) {
|
|
var i,
|
|
ii,
|
|
length,
|
|
_endIndex,
|
|
_beginIndex,
|
|
value = input;
|
|
|
|
// apply replacements in string
|
|
if (typeof input === STRING) {
|
|
return this._applyInString(input, beginIndex, endIndex);
|
|
}
|
|
|
|
// apply replacements in the splitted string (Array)
|
|
_beginIndex = beginIndex;
|
|
|
|
// traverse all the segments until all the replacements are patched
|
|
for (i = 0, ii = input.length; i < ii; ++i) {
|
|
value = input[i];
|
|
_endIndex = _beginIndex + (length = value.length);
|
|
|
|
// apply replacements applicable for individual segment
|
|
input[i] = this._applyInString(value, _beginIndex, _endIndex);
|
|
_beginIndex += length + 1; // + 1 separator
|
|
}
|
|
|
|
return input;
|
|
};
|
|
|
|
/**
|
|
* Normalize the given string by replacing the variables which includes
|
|
* reserved characters in its name.
|
|
* The replaced characters are added to the given replacement tracker instance.
|
|
*
|
|
* @private
|
|
* @param {String} str - string to normalize
|
|
* @param {ReplacementTracker} replacements - tracker to store replacements
|
|
* @returns {String}
|
|
*/
|
|
function normalizeVariables (str, replacements) {
|
|
var normalizedString = E,
|
|
pointer = 0, // pointer till witch the string is normalized
|
|
variable,
|
|
match,
|
|
index;
|
|
|
|
// find all the instances of {{<variable>}} which includes reserved chars
|
|
// "Hello {{user#name}}!!!"
|
|
// ↑ (pointer = 0)
|
|
while ((match = REGEX_EXTRACT_VARS.exec(str)) !== null) {
|
|
// {{user#name}}
|
|
variable = match[0];
|
|
|
|
// starting index of the {{variable}} in the string
|
|
// "Hello {{user#name}}!!!"
|
|
// ↑ (index = 6)
|
|
index = match.index;
|
|
|
|
// [pointer, index) string is normalized + the safe replacement character
|
|
// "Hello " + "_"
|
|
normalizedString += str.slice(pointer, index) + SAFE_REPLACE_CHAR;
|
|
|
|
// track the replacement done for the {{variable}}
|
|
replacements.add(variable, index);
|
|
|
|
// update the pointer
|
|
// "Hello {{user#name}}!!!"
|
|
// ↑ (pointer = 19)
|
|
pointer = index + variable.length;
|
|
}
|
|
|
|
// avoid slicing the string in case of no matches
|
|
if (pointer === 0) {
|
|
return str;
|
|
}
|
|
|
|
// whatever left in the string is normalized as well
|
|
if (pointer < str.length) {
|
|
// "Hello _" + "!!!"
|
|
normalizedString += str.slice(pointer);
|
|
}
|
|
|
|
return normalizedString;
|
|
}
|
|
|
|
/**
|
|
* Update replaced characters in the URL object with its original value.
|
|
*
|
|
* @private
|
|
* @param {Object} url - url to apply replacements on
|
|
* @param {ReplacementTracker} replacements - tracked replacements
|
|
*/
|
|
function applyReplacements (url, replacements) {
|
|
var i,
|
|
ii,
|
|
prop;
|
|
|
|
// traverse each URL property in the given order
|
|
for (i = 0, ii = URL_PROPERTIES_ORDER.length; i < ii; ++i) {
|
|
prop = url[URL_PROPERTIES_ORDER[i]];
|
|
|
|
// bail out if the given property is not set (undefined or E)
|
|
if (!(prop && prop.value)) {
|
|
continue;
|
|
}
|
|
|
|
prop.value = replacements.apply(prop.value, prop.beginIndex, prop.endIndex);
|
|
}
|
|
|
|
return url;
|
|
}
|
|
|
|
/**
|
|
* Parses the input string by decomposing the URL into constituent parts,
|
|
* such as path, host, port, etc.
|
|
*
|
|
* @private
|
|
* @param {String} urlString - url string to parse
|
|
* @returns {Object}
|
|
*/
|
|
function url_parse (urlString) {
|
|
// trim leading whitespace characters
|
|
urlString = String(urlString).trimLeft();
|
|
|
|
var url = {
|
|
protocol: { value: undefined, beginIndex: 0, endIndex: 0 },
|
|
auth: { value: undefined, beginIndex: 0, endIndex: 0 },
|
|
host: { value: undefined, beginIndex: 0, endIndex: 0 },
|
|
port: { value: undefined, beginIndex: 0, endIndex: 0 },
|
|
path: { value: undefined, beginIndex: 0, endIndex: 0 },
|
|
query: { value: undefined, beginIndex: 0, endIndex: 0 },
|
|
hash: { value: undefined, beginIndex: 0, endIndex: 0 }
|
|
},
|
|
parsedUrl = {
|
|
raw: urlString,
|
|
protocol: undefined,
|
|
auth: undefined,
|
|
host: undefined,
|
|
port: undefined,
|
|
path: undefined,
|
|
query: undefined,
|
|
hash: undefined
|
|
},
|
|
replacements = new ReplacementTracker(),
|
|
pointer = 0,
|
|
length,
|
|
index,
|
|
port;
|
|
|
|
// bail out if input string is empty
|
|
if (!urlString) {
|
|
return parsedUrl;
|
|
}
|
|
|
|
// normalize the given string
|
|
urlString = normalizeVariables(urlString, replacements);
|
|
length = urlString.length;
|
|
|
|
// 1. url.hash
|
|
if ((index = urlString.indexOf(HASH)) !== -1) {
|
|
// extract from the back
|
|
url.hash.value = urlString.slice(index + 1);
|
|
url.hash.beginIndex = pointer + index + 1;
|
|
url.hash.endIndex = pointer + length;
|
|
|
|
urlString = urlString.slice(0, (length = index));
|
|
}
|
|
|
|
// 2. url.query
|
|
if ((index = urlString.indexOf(QUERY_SEPARATOR)) !== -1) {
|
|
// extract from the back
|
|
url.query.value = urlString.slice(index + 1).split(AMPERSAND);
|
|
url.query.beginIndex = pointer + index + 1;
|
|
url.query.endIndex = pointer + length;
|
|
|
|
urlString = urlString.slice(0, (length = index));
|
|
}
|
|
|
|
// 3. url.protocol
|
|
if ((index = urlString.indexOf(PROTOCOL_SEPARATOR)) !== -1) {
|
|
// extract from the front
|
|
url.protocol.value = urlString.slice(0, index);
|
|
url.protocol.beginIndex = pointer;
|
|
url.protocol.endIndex = pointer + index;
|
|
|
|
urlString = urlString.slice(index + 3);
|
|
length -= index + 3;
|
|
pointer += index + 3;
|
|
}
|
|
// protocol can be separated using :\\ as well
|
|
else if ((index = urlString.indexOf(PROTOCOL_SEPARATOR_WITH_BACKSLASH)) !== -1) {
|
|
// extract from the front
|
|
url.protocol.value = urlString.slice(0, index);
|
|
url.protocol.beginIndex = pointer;
|
|
url.protocol.endIndex = pointer + index;
|
|
|
|
urlString = urlString.slice(index + 3);
|
|
length -= index + 3;
|
|
pointer += index + 3;
|
|
}
|
|
|
|
// 4. url.path
|
|
urlString = urlString.replace(/\\/g, '/'); // sanitize path
|
|
if ((index = urlString.indexOf(SLASH)) !== -1) {
|
|
// extract from the back
|
|
url.path.value = urlString.slice(index + 1).split(SLASH);
|
|
url.path.beginIndex = pointer + index + 1;
|
|
url.path.endIndex = pointer + length;
|
|
|
|
urlString = urlString.slice(0, (length = index));
|
|
}
|
|
|
|
// 5. url.auth
|
|
if ((index = urlString.lastIndexOf(AUTH_SEPARATOR)) !== -1) {
|
|
// extract from the front
|
|
url.auth.value = urlString.slice(0, index);
|
|
url.auth.beginIndex = pointer;
|
|
url.auth.endIndex = pointer + index;
|
|
|
|
urlString = urlString.slice(index + 1);
|
|
length -= index + 1;
|
|
pointer += index + 1;
|
|
|
|
// separate username:password
|
|
if ((index = url.auth.value.indexOf(COLON)) === -1) {
|
|
url.auth.value = [url.auth.value];
|
|
}
|
|
else {
|
|
url.auth.value = [url.auth.value.slice(0, index), url.auth.value.slice(index + 1)];
|
|
}
|
|
}
|
|
|
|
// 6. url.port
|
|
if ((index = urlString.lastIndexOf(COLON)) !== -1 &&
|
|
// eslint-disable-next-line lodash/prefer-includes
|
|
(port = urlString.slice(index + 1)).indexOf(CLOSING_SQUARE_BRACKET) === -1
|
|
) {
|
|
// extract from the back
|
|
url.port.value = port;
|
|
url.port.beginIndex = pointer + index + 1;
|
|
url.port.endIndex = pointer + length;
|
|
|
|
urlString = urlString.slice(0, (length = index));
|
|
}
|
|
|
|
// 7. url.host
|
|
if (urlString) {
|
|
url.host.value = urlString.split(DOMAIN_SEPARATOR);
|
|
url.host.beginIndex = pointer;
|
|
url.host.endIndex = pointer + length;
|
|
}
|
|
|
|
// apply replacements back, if any
|
|
replacements.count() && applyReplacements(url, replacements);
|
|
|
|
// finally, prepare parsed url
|
|
parsedUrl.protocol = url.protocol.value;
|
|
parsedUrl.auth = url.auth.value;
|
|
parsedUrl.host = url.host.value;
|
|
parsedUrl.port = url.port.value;
|
|
parsedUrl.path = url.path.value;
|
|
parsedUrl.query = url.query.value;
|
|
parsedUrl.hash = url.hash.value;
|
|
|
|
return parsedUrl;
|
|
}
|
|
|
|
/* eslint-disable object-shorthand */
|
|
module.exports = {
|
|
parse: function (url) {
|
|
if (typeof url !== STRING) {
|
|
url = '';
|
|
}
|
|
|
|
url = url_parse(url);
|
|
|
|
var pathVariables,
|
|
pathVariableKeys = {};
|
|
|
|
if (url.auth) {
|
|
url.auth = {
|
|
user: url.auth[0],
|
|
password: url.auth[1]
|
|
};
|
|
}
|
|
|
|
if (url.query) {
|
|
url.query = url.query.map(parseQueryParam);
|
|
}
|
|
|
|
// extract path variables
|
|
pathVariables = _.transform(url.path, function (res, segment) {
|
|
// check if the segment has path variable prefix followed by the variable name and
|
|
// the variable is not already added in the list.
|
|
if (_.startsWith(segment, COLON) &&
|
|
segment !== COLON &&
|
|
!pathVariableKeys[segment]) {
|
|
pathVariableKeys[segment] = true;
|
|
res.push({ key: segment.slice(1) }); // remove path variable prefix.
|
|
}
|
|
}, []);
|
|
url.variable = pathVariables.length ? pathVariables : undefined;
|
|
|
|
return url;
|
|
},
|
|
|
|
unparse: function (urlObj) {
|
|
var rawUrl = E,
|
|
path,
|
|
queryString,
|
|
authString,
|
|
firstEnabledParam = true;
|
|
|
|
if (urlObj.protocol) {
|
|
rawUrl += (_.endsWith(urlObj.protocol, PROTOCOL_SEPARATOR) ?
|
|
urlObj.protocol : urlObj.protocol + PROTOCOL_SEPARATOR);
|
|
}
|
|
|
|
if (urlObj.auth) {
|
|
if (typeof urlObj.auth.user === STRING) {
|
|
authString = urlObj.auth.user;
|
|
}
|
|
|
|
if (typeof urlObj.auth.password === STRING) {
|
|
!authString && (authString = E);
|
|
authString += COLON + urlObj.auth.password;
|
|
}
|
|
|
|
if (typeof authString === STRING) {
|
|
rawUrl += authString + AUTH_SEPARATOR;
|
|
}
|
|
}
|
|
|
|
if (urlObj.host) {
|
|
rawUrl += (_.isArray(urlObj.host) ? urlObj.host.join(DOMAIN_SEPARATOR) : urlObj.host.toString());
|
|
}
|
|
|
|
if (typeof _.get(urlObj.port, 'toString') === FUNCTION) {
|
|
rawUrl += COLON + urlObj.port.toString();
|
|
}
|
|
|
|
if (urlObj.path) {
|
|
path = (_.isArray(urlObj.path) ? urlObj.path.join(SLASH) : urlObj.path.toString());
|
|
rawUrl += (_.startsWith(path, SLASH) ? path : SLASH + path);
|
|
}
|
|
|
|
if (urlObj.query && urlObj.query.length) {
|
|
queryString = _.reduce(urlObj.query, function (accumulator, param) {
|
|
// ignore disabled params
|
|
if (!param || param.disabled) {
|
|
return accumulator;
|
|
}
|
|
|
|
// don't add '&' for the very first enabled param
|
|
if (firstEnabledParam) {
|
|
firstEnabledParam = false;
|
|
}
|
|
// add '&' before concatenating param
|
|
else {
|
|
accumulator += AMPERSAND;
|
|
}
|
|
|
|
return accumulator + unparseQueryParam(param);
|
|
}, E);
|
|
|
|
// either all the params are disabled or a single param is like { key: '' } (http://localhost?)
|
|
// in that case, query separator ? must be included in the raw URL.
|
|
if (queryString === E && firstEnabledParam) {
|
|
// unset querystring if there are no enabled params
|
|
queryString = undefined;
|
|
}
|
|
|
|
if (typeof queryString === STRING) {
|
|
rawUrl += QUERY_SEPARATOR + queryString;
|
|
}
|
|
}
|
|
|
|
if (typeof urlObj.hash === STRING) {
|
|
rawUrl += HASH + urlObj.hash;
|
|
}
|
|
|
|
return rawUrl;
|
|
}
|
|
};
|