'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 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 options (e.g. headers). // For example, when you create a custom instance and make a request // with no static changes, they won't be normalized again. // // `normalize` operates on dynamic options - they cannot be saved. // For example, `body` is everytime different per request. // When it's done normalizing the new options, it performs merge() // on the prenormalized options and the normalized ones. 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: [], errorCodes: [] }; 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); } if (is.array(options.retry.errorCodes)) { options.retry.errorCodes = new Set(options.retry.errorCodes); } 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({}, 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); } } else if (is(url) === 'URL') { url = urlToOptions(url); } // Override both null/undefined with default protocol options = merge({path: ''}, url, {protocol: url.protocol || 'https:'}, options); for (const hook of options.hooks.init) { const called = hook(options); if (is.promise(called)) { throw new TypeError('The `init` hook must be a synchronous function'); } } 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 || !options.retry.errorCodes.has(error.code)) && (!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;