UNPKG

19.7 kBJavaScriptView Raw
1'use strict'
2
3const _ = require('lodash')
4const debug = require('debug')('nock.common')
5const url = require('url')
6const timers = require('timers')
7
8/**
9 * Normalizes the request options so that it always has `host` property.
10 *
11 * @param {Object} options - a parsed options object of the request
12 */
13function normalizeRequestOptions(options) {
14 options.proto = options.proto || 'http'
15 options.port = options.port || (options.proto === 'http' ? 80 : 443)
16 if (options.host) {
17 debug('options.host:', options.host)
18 if (!options.hostname) {
19 if (options.host.split(':').length === 2) {
20 options.hostname = options.host.split(':')[0]
21 } else {
22 options.hostname = options.host
23 }
24 }
25 }
26 debug('options.hostname in the end: %j', options.hostname)
27 options.host = `${options.hostname || 'localhost'}:${options.port}`
28 debug('options.host in the end: %j', options.host)
29
30 /// lowercase host names
31 ;['hostname', 'host'].forEach(function(attr) {
32 if (options[attr]) {
33 options[attr] = options[attr].toLowerCase()
34 }
35 })
36
37 return options
38}
39
40/**
41 * Returns true if the data contained in buffer can be reconstructed
42 * from its utf8 representation.
43 *
44 * @param {Object} buffer - a Buffer object
45 * @returns {boolean}
46 */
47function isUtf8Representable(buffer) {
48 const utfEncodedBuffer = buffer.toString('utf8')
49 const reconstructedBuffer = Buffer.from(utfEncodedBuffer, 'utf8')
50 return reconstructedBuffer.equals(buffer)
51}
52
53// Array where all information about all the overridden requests are held.
54let requestOverrides = {}
55
56/**
57 * Overrides the current `request` function of `http` and `https` modules with
58 * our own version which intercepts issues HTTP/HTTPS requests and forwards them
59 * to the given `newRequest` function.
60 *
61 * @param {Function} newRequest - a function handling requests; it accepts four arguments:
62 * - proto - a string with the overridden module's protocol name (either `http` or `https`)
63 * - overriddenRequest - the overridden module's request function already bound to module's object
64 * - options - the options of the issued request
65 * - callback - the callback of the issued request
66 */
67function overrideRequests(newRequest) {
68 debug('overriding requests')
69 ;['http', 'https'].forEach(function(proto) {
70 debug('- overriding request for', proto)
71
72 const moduleName = proto // 1 to 1 match of protocol and module is fortunate :)
73 const module = {
74 http: require('http'),
75 https: require('https'),
76 }[moduleName]
77 const overriddenRequest = module.request
78 const overriddenGet = module.get
79
80 if (requestOverrides[moduleName]) {
81 throw new Error(
82 `Module's request already overridden for ${moduleName} protocol.`
83 )
84 }
85
86 // Store the properties of the overridden request so that it can be restored later on.
87 requestOverrides[moduleName] = {
88 module,
89 request: overriddenRequest,
90 get: overriddenGet,
91 }
92 // https://nodejs.org/api/http.html#http_http_request_url_options_callback
93 module.request = function(input, options, callback) {
94 return newRequest(proto, overriddenRequest.bind(module), [
95 input,
96 options,
97 callback,
98 ])
99 }
100 // https://nodejs.org/api/http.html#http_http_get_options_callback
101 module.get = function(input, options, callback) {
102 const req = newRequest(proto, overriddenGet.bind(module), [
103 input,
104 options,
105 callback,
106 ])
107 req.end()
108 return req
109 }
110
111 debug('- overridden request for', proto)
112 })
113}
114
115/**
116 * Restores `request` function of `http` and `https` modules to values they
117 * held before they were overridden by us.
118 */
119function restoreOverriddenRequests() {
120 debug('restoring requests')
121 Object.entries(requestOverrides).forEach(
122 ([proto, { module, request, get }]) => {
123 debug('- restoring request for', proto)
124 module.request = request
125 module.get = get
126 debug('- restored request for', proto)
127 }
128 )
129 requestOverrides = {}
130}
131
132/**
133 * In WHATWG URL vernacular, this returns the origin portion of a URL.
134 * However, the port is not included if it's standard and not already present on the host.
135 */
136function normalizeOrigin(proto, host, port) {
137 const hostHasPort = host.includes(':')
138 const portIsStandard =
139 (proto === 'http' && (port === 80 || port === '80')) ||
140 (proto === 'https' && (port === 443 || port === '443'))
141 const portStr = hostHasPort || portIsStandard ? '' : `:${port}`
142
143 return `${proto}://${host}${portStr}`
144}
145
146/**
147 * Get high level information about request as string
148 * @param {Object} options
149 * @param {string} options.method
150 * @param {number|string} options.port
151 * @param {string} options.proto Set internally. always http or https
152 * @param {string} options.hostname
153 * @param {string} options.path
154 * @param {Object} options.headers
155 * @param {string} body
156 * @return {string}
157 */
158function stringifyRequest(options, body) {
159 const { method = 'GET', path = '', port } = options
160 const origin = normalizeOrigin(options.proto, options.hostname, port)
161
162 const log = {
163 method,
164 url: `${origin}${path}`,
165 headers: options.headers,
166 }
167
168 if (body) {
169 log.body = body
170 }
171
172 return JSON.stringify(log, null, 2)
173}
174
175function isContentEncoded(headers) {
176 const contentEncoding = headers['content-encoding']
177 // TODO-12.x: Replace with `typeof contentEncoding === 'string'`.
178 return _.isString(contentEncoding) && contentEncoding !== ''
179}
180
181function contentEncoding(headers, encoder) {
182 const contentEncoding = headers['content-encoding']
183 return contentEncoding === encoder
184}
185
186function isJSONContent(headers) {
187 // https://tools.ietf.org/html/rfc8259
188 const contentType = String(headers['content-type'] || '').toLowerCase()
189 return contentType.startsWith('application/json')
190}
191
192/**
193 * Return a new object with all field names of the headers lower-cased.
194 *
195 * Duplicates throw an error.
196 */
197function headersFieldNamesToLowerCase(headers) {
198 if (!_.isPlainObject(headers)) {
199 throw Error('Headers must be provided as an object')
200 }
201
202 const lowerCaseHeaders = {}
203 Object.entries(headers).forEach(([fieldName, fieldValue]) => {
204 const key = fieldName.toLowerCase()
205 if (lowerCaseHeaders[key] !== undefined) {
206 throw Error(
207 `Failed to convert header keys to lower case due to field name conflict: ${key}`
208 )
209 }
210 lowerCaseHeaders[key] = fieldValue
211 })
212
213 return lowerCaseHeaders
214}
215
216const headersFieldsArrayToLowerCase = headers => [
217 ...new Set(headers.map(fieldName => fieldName.toLowerCase())),
218]
219
220/**
221 * Converts the various accepted formats of headers into a flat array representing "raw headers".
222 *
223 * Nock allows headers to be provided as a raw array, a plain object, or a Map.
224 *
225 * While all the header names are expected to be strings, the values are left intact as they can
226 * be functions, strings, or arrays of strings.
227 *
228 * https://nodejs.org/api/http.html#http_message_rawheaders
229 */
230function headersInputToRawArray(headers) {
231 if (headers === undefined) {
232 return []
233 }
234
235 if (Array.isArray(headers)) {
236 // If the input is an array, assume it's already in the raw format and simply return a copy
237 // but throw an error if there aren't an even number of items in the array
238 if (headers.length % 2) {
239 throw new Error(
240 `Raw headers must be provided as an array with an even number of items. [fieldName, value, ...]`
241 )
242 }
243 return [...headers]
244 }
245
246 // [].concat(...) is used instead of Array.flat until v11 is the minimum Node version
247 if (_.isMap(headers)) {
248 return [].concat(...Array.from(headers, ([k, v]) => [k.toString(), v]))
249 }
250
251 if (_.isPlainObject(headers)) {
252 return [].concat(...Object.entries(headers))
253 }
254
255 throw new Error(
256 `Headers must be provided as an array of raw values, a Map, or a plain Object. ${headers}`
257 )
258}
259
260/**
261 * Converts an array of raw headers to an object, using the same rules as Nodes `http.IncomingMessage.headers`.
262 *
263 * Header names/keys are lower-cased.
264 */
265function headersArrayToObject(rawHeaders) {
266 if (!Array.isArray(rawHeaders)) {
267 throw Error('Expected a header array')
268 }
269
270 const accumulator = {}
271
272 forEachHeader(rawHeaders, (value, fieldName) => {
273 addHeaderLine(accumulator, fieldName, value)
274 })
275
276 return accumulator
277}
278
279const noDuplicatesHeaders = new Set([
280 'age',
281 'authorization',
282 'content-length',
283 'content-type',
284 'etag',
285 'expires',
286 'from',
287 'host',
288 'if-modified-since',
289 'if-unmodified-since',
290 'last-modified',
291 'location',
292 'max-forwards',
293 'proxy-authorization',
294 'referer',
295 'retry-after',
296 'user-agent',
297])
298
299/**
300 * Set key/value data in accordance with Node's logic for folding duplicate headers.
301 *
302 * The `value` param should be a function, string, or array of strings.
303 *
304 * Node's docs and source:
305 * https://nodejs.org/api/http.html#http_message_headers
306 * https://github.com/nodejs/node/blob/908292cf1f551c614a733d858528ffb13fb3a524/lib/_http_incoming.js#L245
307 *
308 * Header names are lower-cased.
309 * Duplicates in raw headers are handled in the following ways, depending on the header name:
310 * - Duplicates of field names listed in `noDuplicatesHeaders` (above) are discarded.
311 * - `set-cookie` is always an array. Duplicates are added to the array.
312 * - For duplicate `cookie` headers, the values are joined together with '; '.
313 * - For all other headers, the values are joined together with ', '.
314 *
315 * Node's implementation is larger because it highly optimizes for not having to call `toLowerCase()`.
316 * We've opted to always call `toLowerCase` in exchange for a more concise function.
317 *
318 * While Node has the luxury of knowing `value` is always a string, we do an extra step of coercion at the top.
319 */
320function addHeaderLine(headers, name, value) {
321 let values // code below expects `values` to be an array of strings
322 if (typeof value === 'function') {
323 // Function values are evaluated towards the end of the response, before that we use a placeholder
324 // string just to designate that the header exists. Useful when `Content-Type` is set with a function.
325 values = [value.name]
326 } else if (Array.isArray(value)) {
327 values = value.map(String)
328 } else {
329 values = [String(value)]
330 }
331
332 const key = name.toLowerCase()
333 if (key === 'set-cookie') {
334 // Array header -- only Set-Cookie at the moment
335 if (headers['set-cookie'] === undefined) {
336 headers['set-cookie'] = values
337 } else {
338 headers['set-cookie'].push(...values)
339 }
340 } else if (noDuplicatesHeaders.has(key)) {
341 if (headers[key] === undefined) {
342 // Drop duplicates
343 headers[key] = values[0]
344 }
345 } else {
346 if (headers[key] !== undefined) {
347 values = [headers[key], ...values]
348 }
349
350 const separator = key === 'cookie' ? '; ' : ', '
351 headers[key] = values.join(separator)
352 }
353}
354
355/**
356 * Deletes the given `fieldName` property from `headers` object by performing
357 * case-insensitive search through keys.
358 *
359 * @headers {Object} headers - object of header field names and values
360 * @fieldName {String} field name - string with the case-insensitive field name
361 */
362function deleteHeadersField(headers, fieldNameToDelete) {
363 if (!_.isPlainObject(headers)) {
364 throw Error('headers must be an object')
365 }
366
367 // TODO-12.x: Replace with `typeof fieldNameToDelete !== 'string'`.
368 if (!_.isString(fieldNameToDelete)) {
369 throw Error('field name must be a string')
370 }
371
372 const lowerCaseFieldNameToDelete = fieldNameToDelete.toLowerCase()
373
374 // Search through the headers and delete all values whose field name matches the given field name.
375 Object.keys(headers)
376 .filter(fieldName => fieldName.toLowerCase() === lowerCaseFieldNameToDelete)
377 .forEach(fieldName => delete headers[fieldName])
378}
379
380/**
381 * Utility for iterating over a raw headers array.
382 *
383 * The callback is called with:
384 * - The header value. string, array of strings, or a function
385 * - The header field name. string
386 * - Index of the header field in the raw header array.
387 */
388function forEachHeader(rawHeaders, callback) {
389 for (let i = 0; i < rawHeaders.length; i += 2) {
390 callback(rawHeaders[i + 1], rawHeaders[i], i)
391 }
392}
393
394function percentDecode(str) {
395 try {
396 return decodeURIComponent(str.replace(/\+/g, ' '))
397 } catch (e) {
398 return str
399 }
400}
401
402/**
403 * URI encode the provided string, stringently adhering to RFC 3986.
404 *
405 * RFC 3986 reserves !, ', (, ), and * but encodeURIComponent does not encode them so we do it manually.
406 *
407 * https://tools.ietf.org/html/rfc3986
408 * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent
409 */
410function percentEncode(str) {
411 return encodeURIComponent(str).replace(/[!'()*]/g, function(c) {
412 return `%${c
413 .charCodeAt(0)
414 .toString(16)
415 .toUpperCase()}`
416 })
417}
418
419function matchStringOrRegexp(target, pattern) {
420 const targetStr =
421 target === undefined || target === null ? '' : String(target)
422
423 return pattern instanceof RegExp
424 ? pattern.test(targetStr)
425 : targetStr === String(pattern)
426}
427
428/**
429 * Formats a query parameter.
430 *
431 * @param key The key of the query parameter to format.
432 * @param value The value of the query parameter to format.
433 * @param stringFormattingFn The function used to format string values. Can
434 * be used to encode or decode the query value.
435 *
436 * @returns *[] the formatted [key, value] pair.
437 */
438function formatQueryValue(key, value, stringFormattingFn) {
439 // TODO: Probably refactor code to replace `switch(true)` with `if`/`else`.
440 switch (true) {
441 case _.isNumber(value): // fall-through
442 case _.isBoolean(value):
443 value = value.toString()
444 break
445 case value === null:
446 case value === undefined:
447 value = ''
448 break
449 // TODO-12.x: Replace with `typeof value === 'string'`.
450 case _.isString(value):
451 if (stringFormattingFn) {
452 value = stringFormattingFn(value)
453 }
454 break
455 case value instanceof RegExp:
456 break
457 case Array.isArray(value): {
458 value = value.map(function(val, idx) {
459 return formatQueryValue(idx, val, stringFormattingFn)[1]
460 })
461 break
462 }
463 case typeof value === 'object': {
464 value = Object.entries(value).reduce(function(acc, [subKey, subVal]) {
465 const subPair = formatQueryValue(subKey, subVal, stringFormattingFn)
466 acc[subPair[0]] = subPair[1]
467
468 return acc
469 }, {})
470 break
471 }
472 }
473
474 if (stringFormattingFn) key = stringFormattingFn(key)
475 return [key, value]
476}
477
478function isStream(obj) {
479 return (
480 obj &&
481 typeof obj !== 'string' &&
482 !Buffer.isBuffer(obj) &&
483 typeof obj.setEncoding === 'function'
484 )
485}
486
487/**
488 * Converts the arguments from the various signatures of http[s].request into a standard
489 * options object and an optional callback function.
490 *
491 * https://nodejs.org/api/http.html#http_http_request_url_options_callback
492 *
493 * Taken from the beginning of the native `ClientRequest`.
494 * https://github.com/nodejs/node/blob/908292cf1f551c614a733d858528ffb13fb3a524/lib/_http_client.js#L68
495 */
496function normalizeClientRequestArgs(input, options, cb) {
497 if (typeof input === 'string') {
498 input = urlToOptions(new url.URL(input))
499 } else if (input instanceof url.URL) {
500 input = urlToOptions(input)
501 } else {
502 cb = options
503 options = input
504 input = null
505 }
506
507 if (typeof options === 'function') {
508 cb = options
509 options = input || {}
510 } else {
511 options = Object.assign(input || {}, options)
512 }
513
514 return { options, callback: cb }
515}
516
517/**
518 * Utility function that converts a URL object into an ordinary
519 * options object as expected by the http.request and https.request APIs.
520 *
521 * This was copied from Node's source
522 * https://github.com/nodejs/node/blob/908292cf1f551c614a733d858528ffb13fb3a524/lib/internal/url.js#L1257
523 */
524function urlToOptions(url) {
525 const options = {
526 protocol: url.protocol,
527 hostname:
528 typeof url.hostname === 'string' && url.hostname.startsWith('[')
529 ? url.hostname.slice(1, -1)
530 : url.hostname,
531 hash: url.hash,
532 search: url.search,
533 pathname: url.pathname,
534 path: `${url.pathname}${url.search || ''}`,
535 href: url.href,
536 }
537 if (url.port !== '') {
538 options.port = Number(url.port)
539 }
540 if (url.username || url.password) {
541 options.auth = `${url.username}:${url.password}`
542 }
543 return options
544}
545
546/**
547 * Determines if request data matches the expected schema.
548 *
549 * Used for comparing decoded search parameters, request body JSON objects,
550 * and URL decoded request form bodies.
551 *
552 * Performs a general recursive strict comparision with two caveats:
553 * - The expected data can use regexp to compare values
554 * - JSON path notation and nested objects are considered equal
555 */
556const dataEqual = (expected, actual) =>
557 deepEqual(expand(expected), expand(actual))
558
559/**
560 * Converts flat objects whose keys use JSON path notation to nested objects.
561 *
562 * The input object is not mutated.
563 *
564 * @example
565 * { 'foo[bar][0]': 'baz' } -> { foo: { bar: [ 'baz' ] } }
566 */
567const expand = input =>
568 Object.entries(input).reduce((acc, [k, v]) => _.set(acc, k, v), {})
569
570/**
571 * Performs a recursive strict comparison between two values.
572 *
573 * Expected values or leaf nodes of expected object values that are RegExp use test() for comparison.
574 */
575function deepEqual(expected, actual) {
576 debug('deepEqual comparing', typeof expected, expected, typeof actual, actual)
577 if (expected instanceof RegExp) {
578 return expected.test(actual)
579 }
580
581 if (Array.isArray(expected) || _.isPlainObject(expected)) {
582 if (actual === undefined) {
583 return false
584 }
585
586 const expKeys = Object.keys(expected)
587 if (expKeys.length !== Object.keys(actual).length) {
588 return false
589 }
590
591 return expKeys.every(key => deepEqual(expected[key], actual[key]))
592 }
593
594 return expected === actual
595}
596
597const timeouts = []
598const intervals = []
599const immediates = []
600
601const wrapTimer = (timer, ids) => (...args) => {
602 const id = timer(...args)
603 ids.push(id)
604 return id
605}
606
607const setTimeout = wrapTimer(timers.setTimeout, timeouts)
608const setInterval = wrapTimer(timers.setInterval, intervals)
609const setImmediate = wrapTimer(timers.setImmediate, immediates)
610
611function clearTimer(clear, ids) {
612 while (ids.length) {
613 clear(ids.shift())
614 }
615}
616
617function removeAllTimers() {
618 clearTimer(clearTimeout, timeouts)
619 clearTimer(clearInterval, intervals)
620 clearTimer(clearImmediate, immediates)
621}
622
623exports.normalizeClientRequestArgs = normalizeClientRequestArgs
624exports.normalizeRequestOptions = normalizeRequestOptions
625exports.normalizeOrigin = normalizeOrigin
626exports.isUtf8Representable = isUtf8Representable
627exports.overrideRequests = overrideRequests
628exports.restoreOverriddenRequests = restoreOverriddenRequests
629exports.stringifyRequest = stringifyRequest
630exports.isContentEncoded = isContentEncoded
631exports.contentEncoding = contentEncoding
632exports.isJSONContent = isJSONContent
633exports.headersFieldNamesToLowerCase = headersFieldNamesToLowerCase
634exports.headersFieldsArrayToLowerCase = headersFieldsArrayToLowerCase
635exports.headersArrayToObject = headersArrayToObject
636exports.headersInputToRawArray = headersInputToRawArray
637exports.deleteHeadersField = deleteHeadersField
638exports.forEachHeader = forEachHeader
639exports.percentEncode = percentEncode
640exports.percentDecode = percentDecode
641exports.matchStringOrRegexp = matchStringOrRegexp
642exports.formatQueryValue = formatQueryValue
643exports.isStream = isStream
644exports.dataEqual = dataEqual
645exports.setTimeout = setTimeout
646exports.setInterval = setInterval
647exports.setImmediate = setImmediate
648exports.removeAllTimers = removeAllTimers