|
'use strict';
|
|
const {URL, URLSearchParams} = require('url'); // TODO: Use the `URL` global when targeting Node.js 10
|
|
const urlLib = require('url');
|
|
const is = require('@sindresorhus/is');
|
|
const urlParseLax = require('url-parse-lax');
|
|
const lowercaseKeys = require('lowercase-keys');
|
|
const isRetryOnNetworkErrorAllowed = require('./utils/is-retry-on-network-error-allowed');
|
|
const urlToOptions = require('./utils/url-to-options');
|
|
const isFormData = require('./utils/is-form-data');
|
|
const merge = require('./merge');
|
|
const knownHookEvents = require('./known-hook-events');
|
|
|
|
const retryAfterStatusCodes = new Set([413, 429, 503]);
|
|
|
|
// `preNormalize` handles static things (lowercasing headers; normalizing baseUrl, timeout, retry)
|
|
// While `normalize` does `preNormalize` + handles things which need to be reworked when user changes them
|
|
const preNormalize = (options, defaults) => {
|
|
if (is.nullOrUndefined(options.headers)) {
|
|
options.headers = {};
|
|
} else {
|
|
options.headers = lowercaseKeys(options.headers);
|
|
}
|
|
|
|
if (options.baseUrl && !options.baseUrl.toString().endsWith('/')) {
|
|
options.baseUrl += '/';
|
|
}
|
|
|
|
if (options.stream) {
|
|
options.json = false;
|
|
}
|
|
|
|
if (is.nullOrUndefined(options.hooks)) {
|
|
options.hooks = {};
|
|
} else if (!is.object(options.hooks)) {
|
|
throw new TypeError(`Parameter \`hooks\` must be an object, not ${is(options.hooks)}`);
|
|
}
|
|
|
|
for (const event of knownHookEvents) {
|
|
if (is.nullOrUndefined(options.hooks[event])) {
|
|
if (defaults) {
|
|
options.hooks[event] = [...defaults.hooks[event]];
|
|
} else {
|
|
options.hooks[event] = [];
|
|
}
|
|
}
|
|
}
|
|
|
|
if (is.number(options.timeout)) {
|
|
options.gotTimeout = {request: options.timeout};
|
|
} else if (is.object(options.timeout)) {
|
|
options.gotTimeout = options.timeout;
|
|
}
|
|
delete options.timeout;
|
|
|
|
const {retry} = options;
|
|
options.retry = {
|
|
retries: 0,
|
|
methods: [],
|
|
statusCodes: []
|
|
};
|
|
|
|
if (is.nonEmptyObject(defaults) && retry !== false) {
|
|
options.retry = {...defaults.retry};
|
|
}
|
|
|
|
if (retry !== false) {
|
|
if (is.number(retry)) {
|
|
options.retry.retries = retry;
|
|
} else {
|
|
options.retry = {...options.retry, ...retry};
|
|
}
|
|
}
|
|
|
|
if (options.gotTimeout) {
|
|
options.retry.maxRetryAfter = Math.min(...[options.gotTimeout.request, options.gotTimeout.connection].filter(n => !is.nullOrUndefined(n)));
|
|
}
|
|
|
|
if (is.array(options.retry.methods)) {
|
|
options.retry.methods = new Set(options.retry.methods.map(method => method.toUpperCase()));
|
|
}
|
|
|
|
if (is.array(options.retry.statusCodes)) {
|
|
options.retry.statusCodes = new Set(options.retry.statusCodes);
|
|
}
|
|
|
|
return options;
|
|
};
|
|
|
|
const normalize = (url, options, defaults) => {
|
|
if (is.plainObject(url)) {
|
|
options = {...url, ...options};
|
|
url = options.url || {};
|
|
delete options.url;
|
|
}
|
|
|
|
if (defaults) {
|
|
options = merge({}, defaults.options, options ? preNormalize(options, defaults.options) : {});
|
|
} else {
|
|
options = merge({}, options ? preNormalize(options) : {});
|
|
}
|
|
|
|
if (!is.string(url) && !is.object(url)) {
|
|
throw new TypeError(`Parameter \`url\` must be a string or object, not ${is(url)}`);
|
|
}
|
|
|
|
if (is.string(url)) {
|
|
if (options.baseUrl) {
|
|
if (url.toString().startsWith('/')) {
|
|
url = url.toString().slice(1);
|
|
}
|
|
|
|
url = urlToOptions(new URL(url, options.baseUrl));
|
|
} else {
|
|
url = url.replace(/^unix:/, 'http://$&');
|
|
|
|
url = urlParseLax(url);
|
|
if (url.auth) {
|
|
throw new Error('Basic authentication must be done with the `auth` option');
|
|
}
|
|
}
|
|
} else if (is(url) === 'URL') {
|
|
url = urlToOptions(url);
|
|
}
|
|
|
|
// Override both null/undefined with default protocol
|
|
options = merge({path: ''}, url, {protocol: url.protocol || 'https:'}, options);
|
|
|
|
const {baseUrl} = options;
|
|
Object.defineProperty(options, 'baseUrl', {
|
|
set: () => {
|
|
throw new Error('Failed to set baseUrl. Options are normalized already.');
|
|
},
|
|
get: () => baseUrl
|
|
});
|
|
|
|
const {query} = options;
|
|
if (is.nonEmptyString(query) || is.nonEmptyObject(query) || query instanceof URLSearchParams) {
|
|
if (!is.string(query)) {
|
|
options.query = (new URLSearchParams(query)).toString();
|
|
}
|
|
options.path = `${options.path.split('?')[0]}?${options.query}`;
|
|
delete options.query;
|
|
}
|
|
|
|
if (options.hostname === 'unix') {
|
|
const matches = /(.+?):(.+)/.exec(options.path);
|
|
|
|
if (matches) {
|
|
const [, socketPath, path] = matches;
|
|
options = {
|
|
...options,
|
|
socketPath,
|
|
path,
|
|
host: null
|
|
};
|
|
}
|
|
}
|
|
|
|
const {headers} = options;
|
|
for (const [key, value] of Object.entries(headers)) {
|
|
if (is.nullOrUndefined(value)) {
|
|
delete headers[key];
|
|
}
|
|
}
|
|
|
|
if (options.json && is.undefined(headers.accept)) {
|
|
headers.accept = 'application/json';
|
|
}
|
|
|
|
if (options.decompress && is.undefined(headers['accept-encoding'])) {
|
|
headers['accept-encoding'] = 'gzip, deflate';
|
|
}
|
|
|
|
const {body} = options;
|
|
if (is.nullOrUndefined(body)) {
|
|
options.method = options.method ? options.method.toUpperCase() : 'GET';
|
|
} else {
|
|
const isObject = is.object(body) && !is.buffer(body) && !is.nodeStream(body);
|
|
if (!is.nodeStream(body) && !is.string(body) && !is.buffer(body) && !(options.form || options.json)) {
|
|
throw new TypeError('The `body` option must be a stream.Readable, string or Buffer');
|
|
}
|
|
|
|
if (options.json && !(isObject || is.array(body))) {
|
|
throw new TypeError('The `body` option must be an Object or Array when the `json` option is used');
|
|
}
|
|
|
|
if (options.form && !isObject) {
|
|
throw new TypeError('The `body` option must be an Object when the `form` option is used');
|
|
}
|
|
|
|
if (isFormData(body)) {
|
|
// Special case for https://github.com/form-data/form-data
|
|
headers['content-type'] = headers['content-type'] || `multipart/form-data; boundary=${body.getBoundary()}`;
|
|
} else if (options.form) {
|
|
headers['content-type'] = headers['content-type'] || 'application/x-www-form-urlencoded';
|
|
options.body = (new URLSearchParams(body)).toString();
|
|
} else if (options.json) {
|
|
headers['content-type'] = headers['content-type'] || 'application/json';
|
|
options.body = JSON.stringify(body);
|
|
}
|
|
|
|
options.method = options.method ? options.method.toUpperCase() : 'POST';
|
|
}
|
|
|
|
if (!is.function(options.retry.retries)) {
|
|
const {retries} = options.retry;
|
|
|
|
options.retry.retries = (iteration, error) => {
|
|
if (iteration > retries) {
|
|
return 0;
|
|
}
|
|
|
|
if (error !== null) {
|
|
if (!isRetryOnNetworkErrorAllowed(error) && (!options.retry.methods.has(error.method) || !options.retry.statusCodes.has(error.statusCode))) {
|
|
return 0;
|
|
}
|
|
|
|
if (Reflect.has(error, 'headers') && Reflect.has(error.headers, 'retry-after') && retryAfterStatusCodes.has(error.statusCode)) {
|
|
let after = Number(error.headers['retry-after']);
|
|
if (is.nan(after)) {
|
|
after = Date.parse(error.headers['retry-after']) - Date.now();
|
|
} else {
|
|
after *= 1000;
|
|
}
|
|
|
|
if (after > options.retry.maxRetryAfter) {
|
|
return 0;
|
|
}
|
|
|
|
return after;
|
|
}
|
|
|
|
if (error.statusCode === 413) {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
const noise = Math.random() * 100;
|
|
return ((2 ** (iteration - 1)) * 1000) + noise;
|
|
};
|
|
}
|
|
|
|
return options;
|
|
};
|
|
|
|
const reNormalize = options => normalize(urlLib.format(options), options);
|
|
|
|
module.exports = normalize;
|
|
module.exports.preNormalize = preNormalize;
|
|
module.exports.reNormalize = reNormalize;
|