/**
* Implementation of the WHATWG URL Standard.
*
* @example
* const urlEncoder = require('postman-url-encoder')
*
* // Encoding URL string to Node.js compatible Url object
* urlEncoder.toNodeUrl('郵便屋さん.com/foo&bar/{baz}?q=("foo")#`hash`')
*
* // Encoding URI component
* urlEncoder.encode('qüêry štrìng')
*
* // Encoding query string object
* urlEncoder.encodeQueryString({ q1: 'foo', q2: ['bãr', 'baž'] })
*
* @module postman-url-encoder
* @see {@link https://url.spec.whatwg.org}
*/
const querystring = require('querystring'),
legacy = require('./legacy'),
parser = require('./parser'),
encoder = require('./encoder'),
QUERY_ENCODE_SET = require('./encoder/encode-set').QUERY_ENCODE_SET,
E = '',
COLON = ':',
BACK_SLASH = '\\',
DOUBLE_SLASH = '//',
DOUBLE_BACK_SLASH = '\\\\',
STRING = 'string',
OBJECT = 'object',
FUNCTION = 'function',
DEFAULT_PROTOCOL = 'http',
LEFT_SQUARE_BRACKET = '[',
RIGHT_SQUARE_BRACKET = ']',
PATH_SEPARATOR = '/',
QUERY_SEPARATOR = '?',
PARAMS_SEPARATOR = '&',
SEARCH_SEPARATOR = '#',
DOMAIN_SEPARATOR = '.',
AUTH_CREDENTIALS_SEPARATOR = '@',
// @note this regular expression is referred from Node.js URL parser
PROTOCOL_RE = /^[a-z0-9.+-]+:(?:\/\/|\\\\)./i,
/**
* Protocols that always contain a // bit.
*
* @private
* @see {@link https://github.com/nodejs/node/blob/v10.17.0/lib/url.js#L91}
*/
SLASHED_PROTOCOLS = {
'file:': true,
'ftp:': true,
'gopher:': true,
'http:': true,
'https:': true,
'ws:': true,
'wss:': true
};
/**
* Returns stringified URL from Url object but only includes parts till given
* part name.
*
* @example
* var url = 'http://postman.com/foo?q=v#hash';
* getUrlTill(toNodeUrl(url), 'host')
* // returns 'http://postman.com'
*
* @private
* @param {Object} url base URL
* @param {String} [urlPart='query'] one of ['host', 'pathname', 'query']
*/
function getUrlTill (url, urlPart) {
let result = '';
if (url.protocol) {
result += url.protocol + DOUBLE_SLASH;
}
if (url.auth) {
result += url.auth + AUTH_CREDENTIALS_SEPARATOR;
}
result += url.host || E;
if (urlPart === 'host') { return result; }
result += url.pathname;
if (urlPart === 'pathname') { return result; }
// urlPart must be query at this point
return result + (url.search || E);
}
/**
* Percent-encode the given string using QUERY_ENCODE_SET.
*
* @deprecated since version 2.0, use {@link encodeQueryParam} instead.
*
* @example
* // returns 'foo%20%22%23%26%27%3C%3D%3E%20bar'
* encode('foo "#&\'<=> bar')
*
* // returns ''
* encode(['foobar'])
*
* @param {String} value String to percent-encode
* @returns {String} Percent-encoded string
*/
function encode (value) {
return encoder.percentEncode(value, QUERY_ENCODE_SET);
}
/**
* Percent-encode the URL query string or x-www-form-urlencoded body object
* according to RFC3986.
*
* @example
* // returns 'q1=foo&q2=bar&q2=baz'
* encodeQueryString({ q1: 'foo', q2: ['bar', 'baz'] })
*
* @param {Object} query Object representing query or urlencoded body
* @returns {String} Percent-encoded string
*/
function encodeQueryString (query) {
if (!(query && typeof query === OBJECT)) {
return E;
}
// rely upon faster querystring module
query = querystring.stringify(query);
// encode characters not encoded by querystring.stringify() according to RFC3986.
return query.replace(/[!'()*]/g, function (c) {
return encoder.percentEncodeCharCode(c.charCodeAt(0));
});
}
/**
* Converts PostmanUrl / URL string into Node.js compatible Url object.
*
* @example <caption>Using URL string</caption>
* toNodeUrl('郵便屋さん.com/foo&bar/{baz}?q=("foo")#`hash`')
* // returns
* // {
* // protocol: 'http:',
* // slashes: true,
* // auth: null,
* // host: 'xn--48jwgn17gdel797d.com',
* // port: null,
* // hostname: 'xn--48jwgn17gdel797d.com',
* // hash: '#%60hash%60',
* // search: '?q=(%22foo%22)',
* // query: 'q=(%22foo%22)',
* // pathname: '/foo&bar/%7Bbaz%7D',
* // path: '/foo&bar/%7Bbaz%7D?q=(%22foo%22)',
* // href: 'http://xn--48jwgn17gdel797d.com/foo&bar/%7Bbaz%7D?q=(%22foo%22)#%60hash%60'
* // }
*
* @example <caption>Using PostmanUrl instance</caption>
* toNodeUrl(new sdk.Url({
* host: 'example.com',
* query: [{ key: 'foo', value: 'bar & baz' }]
* }))
*
* @param {PostmanUrl|String} url URL string or PostmanUrl object
* @param {Boolean} disableEncoding Turn encoding off
* @returns {Url} Node.js like parsed and encoded object
*/
function toNodeUrl (url, disableEncoding) {
let nodeUrl = {
protocol: null,
slashes: null,
auth: null,
host: null,
port: null,
hostname: null,
hash: null,
search: null,
query: null,
pathname: null,
path: null,
href: E
},
port,
hostname,
pathname,
authUser,
queryParams,
authPassword;
// Check if PostmanUrl instance and prepare segments
if (url && url.constructor && url.constructor._postman_propertyName === 'Url') {
// @note getPath() always adds a leading '/', similar to Node.js API
pathname = url.getPath();
hostname = url.getHost().toLowerCase();
if (url.query && url.query.count()) {
queryParams = url.getQueryString({ ignoreDisabled: true });
queryParams = disableEncoding ? queryParams : encoder.encodeQueryParam(queryParams);
// 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.
// @todo Add helper in SDK to handle this
if (queryParams === E) {
// check if there's any enabled param, if so, set queryString to empty string
// otherwise (all disabled), it will be set as undefined
queryParams = url.query.find(function (param) { return !(param && param.disabled); }) && E;
}
}
if (url.auth) {
authUser = url.auth.user;
authPassword = url.auth.password;
}
}
// Parser URL string and prepare segments
else if (typeof url === STRING) {
url = parser.parse(url);
pathname = PATH_SEPARATOR + (url.path || []).join(PATH_SEPARATOR);
hostname = (url.host || []).join(DOMAIN_SEPARATOR).toLowerCase();
queryParams = url.query && (queryParams = url.query.join(PARAMS_SEPARATOR)) &&
(disableEncoding ? queryParams : encoder.encodeQueryParam(queryParams));
authUser = (url.auth || [])[0];
authPassword = (url.auth || [])[1];
}
// bail out with empty URL object for invalid input
else {
return nodeUrl;
}
// @todo Add helper in SDK to normalize port
// eslint-disable-next-line no-eq-null, eqeqeq
if (!(url.port == null) && typeof url.port.toString === FUNCTION) {
port = url.port.toString();
}
// #protocol
nodeUrl.protocol = (typeof url.protocol === STRING) ? url.protocol.toLowerCase() : DEFAULT_PROTOCOL;
nodeUrl.protocol += COLON;
// #slashes
nodeUrl.slashes = SLASHED_PROTOCOLS[nodeUrl.protocol] || false;
// #href = protocol://
nodeUrl.href = nodeUrl.protocol + DOUBLE_SLASH;
// #auth
if (url.auth) {
if (typeof authUser === STRING) {
nodeUrl.auth = disableEncoding ? authUser : encoder.encodeUserInfo(authUser);
}
if (typeof authPassword === STRING) {
!nodeUrl.auth && (nodeUrl.auth = E);
nodeUrl.auth += COLON + (disableEncoding ? authPassword : encoder.encodeUserInfo(authPassword));
}
if (typeof nodeUrl.auth === STRING) {
// #href = protocol://user:password@
nodeUrl.href += nodeUrl.auth + AUTH_CREDENTIALS_SEPARATOR;
}
}
// #host, #hostname
nodeUrl.host = nodeUrl.hostname = hostname = encoder.encodeHost(hostname); // @note always encode hostname
// #href = protocol://user:password@host.name
nodeUrl.href += nodeUrl.hostname;
// #port
if (typeof port === STRING) {
nodeUrl.port = port;
// #host = (#hostname):(#port)
nodeUrl.host = nodeUrl.hostname + COLON + port;
// #href = protocol://user:password@host.name:port
nodeUrl.href += COLON + port;
}
// #path, #pathname
nodeUrl.path = nodeUrl.pathname = disableEncoding ? pathname : encoder.encodePath(pathname);
// #href = protocol://user:password@host.name:port/p/a/t/h
nodeUrl.href += nodeUrl.pathname;
if (typeof queryParams === STRING) {
// #query
nodeUrl.query = queryParams;
// #search
nodeUrl.search = QUERY_SEPARATOR + nodeUrl.query;
// #path = (#pathname)?(#search)
nodeUrl.path = nodeUrl.pathname + nodeUrl.search;
// #href = protocol://user:password@host.name:port/p/a/t/h?q=query
nodeUrl.href += nodeUrl.search;
}
if (typeof url.hash === STRING) {
// #hash
nodeUrl.hash = SEARCH_SEPARATOR + (disableEncoding ? url.hash : encoder.encodeFragment(url.hash));
// #href = protocol://user:password@host.name:port/p/a/t/h?q=query#hash
nodeUrl.href += nodeUrl.hash;
}
// Finally apply Node.js shenanigans
// # Remove square brackets from IPv6 #hostname
// Refer: https://github.com/nodejs/node/blob/v12.18.3/lib/url.js#L399
// Refer: https://github.com/nodejs/node/blob/v12.18.3/lib/internal/url.js#L1273
if (hostname[0] === LEFT_SQUARE_BRACKET && hostname[hostname.length - 1] === RIGHT_SQUARE_BRACKET) {
nodeUrl.hostname = hostname.slice(1, -1);
}
return nodeUrl;
}
/**
* Resolves a relative URL with respect to given base URL.
* This is a replacement method for Node's url.resolve() which is compatible
* with URL object generated by toNodeUrl().
*
* @example
* // returns 'http://postman.com/baz'
* resolveNodeUrl('http://postman.com/foo/bar', '/baz')
*
* @param {Object|String} base URL string or toNodeUrl() object
* @param {String} relative Relative URL to resolve
* @returns {String} Resolved URL
*/
function resolveNodeUrl (base, relative) {
// normalize arguments
typeof base === STRING && (base = toNodeUrl(base));
typeof relative !== STRING && (relative = E);
// bail out if base is not an object
if (!(base && typeof base === OBJECT)) {
return relative;
}
let i,
ii,
index,
baseHref,
relative_0,
relative_01,
basePathname,
requiredProps = ['protocol', 'auth', 'host', 'pathname', 'search', 'href'];
// bail out if base is not like Node url object
for (i = 0, ii = requiredProps.length; i < ii; i++) {
if (!Object.hasOwnProperty.call(base, requiredProps[i])) {
return relative;
}
}
// cache base.href and base.pathname
baseHref = base.href;
basePathname = base.pathname;
// cache relative's first two chars
relative_0 = relative.slice(0, 1);
relative_01 = relative.slice(0, 2);
// @note relative can be one of
// #1 empty string
// #2 protocol relative, starts with // or \\
// #3 path relative, starts with / or \
// #4 just query or hash, starts with ? or #
// #5 absolute URL, starts with :// or :\\
// #6 free from path, with or without query and hash
// #1 empty string
if (!relative) {
// return base as it is if there is no hash
if ((index = baseHref.indexOf(SEARCH_SEPARATOR)) === -1) {
return baseHref;
}
// else, return base without the hash
return baseHref.slice(0, index);
}
// #2 protocol relative, starts with // or \\
// @note \\ is not converted to //
if (relative_01 === DOUBLE_SLASH || relative_01 === DOUBLE_BACK_SLASH) {
return base.protocol + relative;
}
// #3 path relative, starts with / or \
// @note \(s) are not converted to /
if (relative_0 === PATH_SEPARATOR || relative_0 === BACK_SLASH) {
return getUrlTill(base, 'host') + relative;
}
// #4 just hash, starts with #
if (relative_0 === SEARCH_SEPARATOR) {
return getUrlTill(base, 'query') + relative;
}
// #4 just query, starts with ?
if (relative_0 === QUERY_SEPARATOR) {
return getUrlTill(base, 'pathname') + relative;
}
// #5 absolute URL, starts with :// or :\\
// @note :\\ is not converted to ://
if (PROTOCOL_RE.test(relative)) {
return relative;
}
// #6 free from path, with or without query and hash
// remove last path segment form base path
basePathname = basePathname.slice(0, basePathname.lastIndexOf(PATH_SEPARATOR) + 1);
return getUrlTill(base, 'host') + basePathname + relative;
}
/**
* Converts URL string into Node.js compatible Url object using the v1 encoder.
*
* @deprecated since version 2.0
*
* @param {String} url URL string
* @returns {Url} Node.js compatible Url object
*/
function toLegacyNodeUrl (url) {
return legacy.toNodeUrl(url);
}
module.exports = {
encode,
toNodeUrl,
resolveNodeUrl,
toLegacyNodeUrl,
encodeQueryString
};