UNPKG

21 kBJavaScriptView Raw
1'use strict'
2
3const debug = require('debug')('nock.common')
4const set = require('lodash.set')
5const timers = require('timers')
6const url = require('url')
7const util = require('util')
8
9/**
10 * Normalizes the request options so that it always has `host` property.
11 *
12 * @param {Object} options - a parsed options object of the request
13 */
14function normalizeRequestOptions(options) {
15 options.proto = options.proto || 'http'
16 options.port = options.port || (options.proto === 'http' ? 80 : 443)
17 if (options.host) {
18 debug('options.host:', options.host)
19 if (!options.hostname) {
20 if (options.host.split(':').length === 2) {
21 options.hostname = options.host.split(':')[0]
22 } else {
23 options.hostname = options.host
24 }
25 }
26 }
27 debug('options.hostname in the end: %j', options.hostname)
28 options.host = `${options.hostname || 'localhost'}:${options.port}`
29 debug('options.host in the end: %j', options.host)
30
31 /// lowercase host names
32 ;['hostname', 'host'].forEach(function (attr) {
33 if (options[attr]) {
34 options[attr] = options[attr].toLowerCase()
35 }
36 })
37
38 return options
39}
40
41/**
42 * Returns true if the data contained in buffer can be reconstructed
43 * from its utf8 representation.
44 *
45 * @param {Object} buffer - a Buffer object
46 * @returns {boolean}
47 */
48function isUtf8Representable(buffer) {
49 const utfEncodedBuffer = buffer.toString('utf8')
50 const reconstructedBuffer = Buffer.from(utfEncodedBuffer, 'utf8')
51 return reconstructedBuffer.equals(buffer)
52}
53
54// Array where all information about all the overridden requests are held.
55let requestOverrides = {}
56
57/**
58 * Overrides the current `request` function of `http` and `https` modules with
59 * our own version which intercepts issues HTTP/HTTPS requests and forwards them
60 * to the given `newRequest` function.
61 *
62 * @param {Function} newRequest - a function handling requests; it accepts four arguments:
63 * - proto - a string with the overridden module's protocol name (either `http` or `https`)
64 * - overriddenRequest - the overridden module's request function already bound to module's object
65 * - options - the options of the issued request
66 * - callback - the callback of the issued request
67 */
68function overrideRequests(newRequest) {
69 debug('overriding requests')
70 ;['http', 'https'].forEach(function (proto) {
71 debug('- overriding request for', proto)
72
73 const moduleName = proto // 1 to 1 match of protocol and module is fortunate :)
74 const module = {
75 http: require('http'),
76 https: require('https'),
77 }[moduleName]
78 const overriddenRequest = module.request
79 const overriddenGet = module.get
80
81 if (requestOverrides[moduleName]) {
82 throw new Error(
83 `Module's request already overridden for ${moduleName} protocol.`
84 )
85 }
86
87 // Store the properties of the overridden request so that it can be restored later on.
88 requestOverrides[moduleName] = {
89 module,
90 request: overriddenRequest,
91 get: overriddenGet,
92 }
93 // https://nodejs.org/api/http.html#http_http_request_url_options_callback
94 module.request = function (input, options, callback) {
95 return newRequest(proto, overriddenRequest.bind(module), [
96 input,
97 options,
98 callback,
99 ])
100 }
101 // https://nodejs.org/api/http.html#http_http_get_options_callback
102 module.get = function (input, options, callback) {
103 const req = newRequest(proto, overriddenGet.bind(module), [
104 input,
105 options,
106 callback,
107 ])
108 req.end()
109 return req
110 }
111
112 debug('- overridden request for', proto)
113 })
114}
115
116/**
117 * Restores `request` function of `http` and `https` modules to values they
118 * held before they were overridden by us.
119 */
120function restoreOverriddenRequests() {
121 debug('restoring requests')
122 Object.entries(requestOverrides).forEach(
123 ([proto, { module, request, get }]) => {
124 debug('- restoring request for', proto)
125 module.request = request
126 module.get = get
127 debug('- restored request for', proto)
128 }
129 )
130 requestOverrides = {}
131}
132
133/**
134 * In WHATWG URL vernacular, this returns the origin portion of a URL.
135 * However, the port is not included if it's standard and not already present on the host.
136 */
137function normalizeOrigin(proto, host, port) {
138 const hostHasPort = host.includes(':')
139 const portIsStandard =
140 (proto === 'http' && (port === 80 || port === '80')) ||
141 (proto === 'https' && (port === 443 || port === '443'))
142 const portStr = hostHasPort || portIsStandard ? '' : `:${port}`
143
144 return `${proto}://${host}${portStr}`
145}
146
147/**
148 * Get high level information about request as string
149 * @param {Object} options
150 * @param {string} options.method
151 * @param {number|string} options.port
152 * @param {string} options.proto Set internally. always http or https
153 * @param {string} options.hostname
154 * @param {string} options.path
155 * @param {Object} options.headers
156 * @param {string} body
157 * @return {string}
158 */
159function stringifyRequest(options, body) {
160 const { method = 'GET', path = '', port } = options
161 const origin = normalizeOrigin(options.proto, options.hostname, port)
162
163 const log = {
164 method,
165 url: `${origin}${path}`,
166 headers: options.headers,
167 }
168
169 if (body) {
170 log.body = body
171 }
172
173 return JSON.stringify(log, null, 2)
174}
175
176function isContentEncoded(headers) {
177 const contentEncoding = headers['content-encoding']
178 return typeof contentEncoding === 'string' && contentEncoding !== ''
179}
180
181function contentEncoding(headers, encoder) {
182 const contentEncoding = headers['content-encoding']
183 return contentEncoding !== undefined && contentEncoding.toString() === 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 (util.types.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 if (typeof fieldNameToDelete !== 'string') {
368 throw Error('field name must be a string')
369 }
370
371 const lowerCaseFieldNameToDelete = fieldNameToDelete.toLowerCase()
372
373 // Search through the headers and delete all values whose field name matches the given field name.
374 Object.keys(headers)
375 .filter(fieldName => fieldName.toLowerCase() === lowerCaseFieldNameToDelete)
376 .forEach(fieldName => delete headers[fieldName])
377}
378
379/**
380 * Utility for iterating over a raw headers array.
381 *
382 * The callback is called with:
383 * - The header value. string, array of strings, or a function
384 * - The header field name. string
385 * - Index of the header field in the raw header array.
386 */
387function forEachHeader(rawHeaders, callback) {
388 for (let i = 0; i < rawHeaders.length; i += 2) {
389 callback(rawHeaders[i + 1], rawHeaders[i], i)
390 }
391}
392
393function percentDecode(str) {
394 try {
395 return decodeURIComponent(str.replace(/\+/g, ' '))
396 } catch (e) {
397 return str
398 }
399}
400
401/**
402 * URI encode the provided string, stringently adhering to RFC 3986.
403 *
404 * RFC 3986 reserves !, ', (, ), and * but encodeURIComponent does not encode them so we do it manually.
405 *
406 * https://tools.ietf.org/html/rfc3986
407 * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent
408 */
409function percentEncode(str) {
410 return encodeURIComponent(str).replace(/[!'()*]/g, function (c) {
411 return `%${c.charCodeAt(0).toString(16).toUpperCase()}`
412 })
413}
414
415function matchStringOrRegexp(target, pattern) {
416 const targetStr =
417 target === undefined || target === null ? '' : String(target)
418
419 if (pattern instanceof RegExp) {
420 // if the regexp happens to have a global flag, we want to ensure we test the entire target
421 pattern.lastIndex = 0
422 return pattern.test(targetStr)
423 }
424 return targetStr === String(pattern)
425}
426
427/**
428 * Formats a query parameter.
429 *
430 * @param key The key of the query parameter to format.
431 * @param value The value of the query parameter to format.
432 * @param stringFormattingFn The function used to format string values. Can
433 * be used to encode or decode the query value.
434 *
435 * @returns *[] the formatted [key, value] pair.
436 */
437function formatQueryValue(key, value, stringFormattingFn) {
438 // TODO: Probably refactor code to replace `switch(true)` with `if`/`else`.
439 switch (true) {
440 case typeof value === 'number': // fall-through
441 case typeof value === 'boolean':
442 value = value.toString()
443 break
444 case value === null:
445 case value === undefined:
446 value = ''
447 break
448 case typeof value === 'string':
449 if (stringFormattingFn) {
450 value = stringFormattingFn(value)
451 }
452 break
453 case value instanceof RegExp:
454 break
455 case Array.isArray(value): {
456 value = value.map(function (val, idx) {
457 return formatQueryValue(idx, val, stringFormattingFn)[1]
458 })
459 break
460 }
461 case typeof value === 'object': {
462 value = Object.entries(value).reduce(function (acc, [subKey, subVal]) {
463 const subPair = formatQueryValue(subKey, subVal, stringFormattingFn)
464 acc[subPair[0]] = subPair[1]
465
466 return acc
467 }, {})
468 break
469 }
470 }
471
472 if (stringFormattingFn) key = stringFormattingFn(key)
473 return [key, value]
474}
475
476function isStream(obj) {
477 return (
478 obj &&
479 typeof obj !== 'string' &&
480 !Buffer.isBuffer(obj) &&
481 typeof obj.setEncoding === 'function'
482 )
483}
484
485/**
486 * Converts the arguments from the various signatures of http[s].request into a standard
487 * options object and an optional callback function.
488 *
489 * https://nodejs.org/api/http.html#http_http_request_url_options_callback
490 *
491 * Taken from the beginning of the native `ClientRequest`.
492 * https://github.com/nodejs/node/blob/908292cf1f551c614a733d858528ffb13fb3a524/lib/_http_client.js#L68
493 */
494function normalizeClientRequestArgs(input, options, cb) {
495 if (typeof input === 'string') {
496 input = urlToOptions(new url.URL(input))
497 } else if (input instanceof url.URL) {
498 input = urlToOptions(input)
499 } else {
500 cb = options
501 options = input
502 input = null
503 }
504
505 if (typeof options === 'function') {
506 cb = options
507 options = input || {}
508 } else {
509 options = Object.assign(input || {}, options)
510 }
511
512 return { options, callback: cb }
513}
514
515/**
516 * Utility function that converts a URL object into an ordinary
517 * options object as expected by the http.request and https.request APIs.
518 *
519 * This was copied from Node's source
520 * https://github.com/nodejs/node/blob/908292cf1f551c614a733d858528ffb13fb3a524/lib/internal/url.js#L1257
521 */
522function urlToOptions(url) {
523 const options = {
524 protocol: url.protocol,
525 hostname:
526 typeof url.hostname === 'string' && url.hostname.startsWith('[')
527 ? url.hostname.slice(1, -1)
528 : url.hostname,
529 hash: url.hash,
530 search: url.search,
531 pathname: url.pathname,
532 path: `${url.pathname}${url.search || ''}`,
533 href: url.href,
534 }
535 if (url.port !== '') {
536 options.port = Number(url.port)
537 }
538 if (url.username || url.password) {
539 options.auth = `${url.username}:${url.password}`
540 }
541 return options
542}
543
544/**
545 * Determines if request data matches the expected schema.
546 *
547 * Used for comparing decoded search parameters, request body JSON objects,
548 * and URL decoded request form bodies.
549 *
550 * Performs a general recursive strict comparision with two caveats:
551 * - The expected data can use regexp to compare values
552 * - JSON path notation and nested objects are considered equal
553 */
554const dataEqual = (expected, actual) => {
555 if (isPlainObject(expected)) {
556 expected = expand(expected)
557 }
558 if (isPlainObject(actual)) {
559 actual = expand(actual)
560 }
561 return deepEqual(expected, actual)
562}
563
564/**
565 * Converts flat objects whose keys use JSON path notation to nested objects.
566 *
567 * The input object is not mutated.
568 *
569 * @example
570 * { 'foo[bar][0]': 'baz' } -> { foo: { bar: [ 'baz' ] } }
571 */
572const expand = input =>
573 Object.entries(input).reduce((acc, [k, v]) => set(acc, k, v), {})
574
575/**
576 * Performs a recursive strict comparison between two values.
577 *
578 * Expected values or leaf nodes of expected object values that are RegExp use test() for comparison.
579 */
580function deepEqual(expected, actual) {
581 debug('deepEqual comparing', typeof expected, expected, typeof actual, actual)
582 if (expected instanceof RegExp) {
583 return expected.test(actual)
584 }
585
586 if (Array.isArray(expected) && Array.isArray(actual)) {
587 if (expected.length !== actual.length) {
588 return false
589 }
590
591 return expected.every((expVal, idx) => deepEqual(expVal, actual[idx]))
592 }
593
594 if (isPlainObject(expected) && isPlainObject(actual)) {
595 const allKeys = Array.from(
596 new Set(Object.keys(expected).concat(Object.keys(actual)))
597 )
598
599 return allKeys.every(key => deepEqual(expected[key], actual[key]))
600 }
601
602 return expected === actual
603}
604
605/**
606 * Checks if `value` is a plain object, that is, an object created by the
607 * `Object` constructor or one with a `[[Prototype]]` of `null`.
608 * https://github.com/lodash/lodash/blob/588bf3e20db0ae039a822a14a8fa238c5b298e65/isPlainObject.js
609 *
610 * @param {*} value The value to check.
611 * @return {boolean}
612 */
613function isPlainObject(value) {
614 const isObjectLike = typeof value === 'object' && value !== null
615 const tag = Object.prototype.toString.call(value)
616 if (!isObjectLike || tag !== '[object Object]') {
617 return false
618 }
619 if (Object.getPrototypeOf(value) === null) {
620 return true
621 }
622 let proto = value
623 while (Object.getPrototypeOf(proto) !== null) {
624 proto = Object.getPrototypeOf(proto)
625 }
626 return Object.getPrototypeOf(value) === proto
627}
628
629/**
630 * Creates an object with the same keys as `object` and values generated
631 * by running each own enumerable string keyed property of `object` thru
632 * `iteratee`. (iteration order is not guaranteed)
633 * The iteratee is invoked with three arguments: (value, key, object).
634 * https://github.com/lodash/lodash/blob/588bf3e20db0ae039a822a14a8fa238c5b298e65/mapValue.js
635 *
636 * @param {Object} object The object to iterate over.
637 * @param {Function} iteratee The function invoked per iteration.
638 * @returns {Object} Returns the new mapped object.
639 */
640function mapValue(object, iteratee) {
641 object = Object(object)
642 const result = {}
643
644 Object.keys(object).forEach(key => {
645 result[key] = iteratee(object[key], key, object)
646 })
647 return result
648}
649
650const timeouts = []
651const intervals = []
652const immediates = []
653
654const wrapTimer = (timer, ids) => (...args) => {
655 const id = timer(...args)
656 ids.push(id)
657 return id
658}
659
660const setTimeout = wrapTimer(timers.setTimeout, timeouts)
661const setInterval = wrapTimer(timers.setInterval, intervals)
662const setImmediate = wrapTimer(timers.setImmediate, immediates)
663
664function clearTimer(clear, ids) {
665 while (ids.length) {
666 clear(ids.shift())
667 }
668}
669
670function removeAllTimers() {
671 clearTimer(clearTimeout, timeouts)
672 clearTimer(clearInterval, intervals)
673 clearTimer(clearImmediate, immediates)
674}
675
676module.exports = {
677 contentEncoding,
678 dataEqual,
679 deleteHeadersField,
680 forEachHeader,
681 formatQueryValue,
682 headersArrayToObject,
683 headersFieldNamesToLowerCase,
684 headersFieldsArrayToLowerCase,
685 headersInputToRawArray,
686 isContentEncoded,
687 isJSONContent,
688 isPlainObject,
689 isStream,
690 isUtf8Representable,
691 mapValue,
692 matchStringOrRegexp,
693 normalizeClientRequestArgs,
694 normalizeOrigin,
695 normalizeRequestOptions,
696 overrideRequests,
697 percentDecode,
698 percentEncode,
699 removeAllTimers,
700 restoreOverriddenRequests,
701 setImmediate,
702 setInterval,
703 setTimeout,
704 stringifyRequest,
705}