UNPKG

19.6 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 return typeof contentEncoding === 'string' && contentEncoding !== ''
178}
179
180function contentEncoding(headers, encoder) {
181 const contentEncoding = headers['content-encoding']
182 return contentEncoding === encoder
183}
184
185function isJSONContent(headers) {
186 // https://tools.ietf.org/html/rfc8259
187 const contentType = String(headers['content-type'] || '').toLowerCase()
188 return contentType.startsWith('application/json')
189}
190
191/**
192 * Return a new object with all field names of the headers lower-cased.
193 *
194 * Duplicates throw an error.
195 */
196function headersFieldNamesToLowerCase(headers) {
197 if (!_.isPlainObject(headers)) {
198 throw Error('Headers must be provided as an object')
199 }
200
201 const lowerCaseHeaders = {}
202 Object.entries(headers).forEach(([fieldName, fieldValue]) => {
203 const key = fieldName.toLowerCase()
204 if (lowerCaseHeaders[key] !== undefined) {
205 throw Error(
206 `Failed to convert header keys to lower case due to field name conflict: ${key}`
207 )
208 }
209 lowerCaseHeaders[key] = fieldValue
210 })
211
212 return lowerCaseHeaders
213}
214
215const headersFieldsArrayToLowerCase = headers => [
216 ...new Set(headers.map(fieldName => fieldName.toLowerCase())),
217]
218
219/**
220 * Converts the various accepted formats of headers into a flat array representing "raw headers".
221 *
222 * Nock allows headers to be provided as a raw array, a plain object, or a Map.
223 *
224 * While all the header names are expected to be strings, the values are left intact as they can
225 * be functions, strings, or arrays of strings.
226 *
227 * https://nodejs.org/api/http.html#http_message_rawheaders
228 */
229function headersInputToRawArray(headers) {
230 if (headers === undefined) {
231 return []
232 }
233
234 if (Array.isArray(headers)) {
235 // If the input is an array, assume it's already in the raw format and simply return a copy
236 // but throw an error if there aren't an even number of items in the array
237 if (headers.length % 2) {
238 throw new Error(
239 `Raw headers must be provided as an array with an even number of items. [fieldName, value, ...]`
240 )
241 }
242 return [...headers]
243 }
244
245 // [].concat(...) is used instead of Array.flat until v11 is the minimum Node version
246 if (_.isMap(headers)) {
247 return [].concat(...Array.from(headers, ([k, v]) => [k.toString(), v]))
248 }
249
250 if (_.isPlainObject(headers)) {
251 return [].concat(...Object.entries(headers))
252 }
253
254 throw new Error(
255 `Headers must be provided as an array of raw values, a Map, or a plain Object. ${headers}`
256 )
257}
258
259/**
260 * Converts an array of raw headers to an object, using the same rules as Nodes `http.IncomingMessage.headers`.
261 *
262 * Header names/keys are lower-cased.
263 */
264function headersArrayToObject(rawHeaders) {
265 if (!Array.isArray(rawHeaders)) {
266 throw Error('Expected a header array')
267 }
268
269 const accumulator = {}
270
271 forEachHeader(rawHeaders, (value, fieldName) => {
272 addHeaderLine(accumulator, fieldName, value)
273 })
274
275 return accumulator
276}
277
278const noDuplicatesHeaders = new Set([
279 'age',
280 'authorization',
281 'content-length',
282 'content-type',
283 'etag',
284 'expires',
285 'from',
286 'host',
287 'if-modified-since',
288 'if-unmodified-since',
289 'last-modified',
290 'location',
291 'max-forwards',
292 'proxy-authorization',
293 'referer',
294 'retry-after',
295 'user-agent',
296])
297
298/**
299 * Set key/value data in accordance with Node's logic for folding duplicate headers.
300 *
301 * The `value` param should be a function, string, or array of strings.
302 *
303 * Node's docs and source:
304 * https://nodejs.org/api/http.html#http_message_headers
305 * https://github.com/nodejs/node/blob/908292cf1f551c614a733d858528ffb13fb3a524/lib/_http_incoming.js#L245
306 *
307 * Header names are lower-cased.
308 * Duplicates in raw headers are handled in the following ways, depending on the header name:
309 * - Duplicates of field names listed in `noDuplicatesHeaders` (above) are discarded.
310 * - `set-cookie` is always an array. Duplicates are added to the array.
311 * - For duplicate `cookie` headers, the values are joined together with '; '.
312 * - For all other headers, the values are joined together with ', '.
313 *
314 * Node's implementation is larger because it highly optimizes for not having to call `toLowerCase()`.
315 * We've opted to always call `toLowerCase` in exchange for a more concise function.
316 *
317 * While Node has the luxury of knowing `value` is always a string, we do an extra step of coercion at the top.
318 */
319function addHeaderLine(headers, name, value) {
320 let values // code below expects `values` to be an array of strings
321 if (typeof value === 'function') {
322 // Function values are evaluated towards the end of the response, before that we use a placeholder
323 // string just to designate that the header exists. Useful when `Content-Type` is set with a function.
324 values = [value.name]
325 } else if (Array.isArray(value)) {
326 values = value.map(String)
327 } else {
328 values = [String(value)]
329 }
330
331 const key = name.toLowerCase()
332 if (key === 'set-cookie') {
333 // Array header -- only Set-Cookie at the moment
334 if (headers['set-cookie'] === undefined) {
335 headers['set-cookie'] = values
336 } else {
337 headers['set-cookie'].push(...values)
338 }
339 } else if (noDuplicatesHeaders.has(key)) {
340 if (headers[key] === undefined) {
341 // Drop duplicates
342 headers[key] = values[0]
343 }
344 } else {
345 if (headers[key] !== undefined) {
346 values = [headers[key], ...values]
347 }
348
349 const separator = key === 'cookie' ? '; ' : ', '
350 headers[key] = values.join(separator)
351 }
352}
353
354/**
355 * Deletes the given `fieldName` property from `headers` object by performing
356 * case-insensitive search through keys.
357 *
358 * @headers {Object} headers - object of header field names and values
359 * @fieldName {String} field name - string with the case-insensitive field name
360 */
361function deleteHeadersField(headers, fieldNameToDelete) {
362 if (!_.isPlainObject(headers)) {
363 throw Error('headers must be an object')
364 }
365
366 if (typeof fieldNameToDelete !== 'string') {
367 throw Error('field name must be a string')
368 }
369
370 const lowerCaseFieldNameToDelete = fieldNameToDelete.toLowerCase()
371
372 // Search through the headers and delete all values whose field name matches the given field name.
373 Object.keys(headers)
374 .filter(fieldName => fieldName.toLowerCase() === lowerCaseFieldNameToDelete)
375 .forEach(fieldName => delete headers[fieldName])
376}
377
378/**
379 * Utility for iterating over a raw headers array.
380 *
381 * The callback is called with:
382 * - The header value. string, array of strings, or a function
383 * - The header field name. string
384 * - Index of the header field in the raw header array.
385 */
386function forEachHeader(rawHeaders, callback) {
387 for (let i = 0; i < rawHeaders.length; i += 2) {
388 callback(rawHeaders[i + 1], rawHeaders[i], i)
389 }
390}
391
392function percentDecode(str) {
393 try {
394 return decodeURIComponent(str.replace(/\+/g, ' '))
395 } catch (e) {
396 return str
397 }
398}
399
400/**
401 * URI encode the provided string, stringently adhering to RFC 3986.
402 *
403 * RFC 3986 reserves !, ', (, ), and * but encodeURIComponent does not encode them so we do it manually.
404 *
405 * https://tools.ietf.org/html/rfc3986
406 * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent
407 */
408function percentEncode(str) {
409 return encodeURIComponent(str).replace(/[!'()*]/g, function(c) {
410 return `%${c
411 .charCodeAt(0)
412 .toString(16)
413 .toUpperCase()}`
414 })
415}
416
417function matchStringOrRegexp(target, pattern) {
418 const targetStr =
419 target === undefined || target === null ? '' : String(target)
420
421 return pattern instanceof RegExp
422 ? pattern.test(targetStr)
423 : targetStr === String(pattern)
424}
425
426/**
427 * Formats a query parameter.
428 *
429 * @param key The key of the query parameter to format.
430 * @param value The value of the query parameter to format.
431 * @param stringFormattingFn The function used to format string values. Can
432 * be used to encode or decode the query value.
433 *
434 * @returns *[] the formatted [key, value] pair.
435 */
436function formatQueryValue(key, value, stringFormattingFn) {
437 // TODO: Probably refactor code to replace `switch(true)` with `if`/`else`.
438 switch (true) {
439 case typeof value === 'number': // fall-through
440 case typeof value === 'boolean':
441 value = value.toString()
442 break
443 case value === null:
444 case value === undefined:
445 value = ''
446 break
447 case typeof value === 'string':
448 if (stringFormattingFn) {
449 value = stringFormattingFn(value)
450 }
451 break
452 case value instanceof RegExp:
453 break
454 case Array.isArray(value): {
455 value = value.map(function(val, idx) {
456 return formatQueryValue(idx, val, stringFormattingFn)[1]
457 })
458 break
459 }
460 case typeof value === 'object': {
461 value = Object.entries(value).reduce(function(acc, [subKey, subVal]) {
462 const subPair = formatQueryValue(subKey, subVal, stringFormattingFn)
463 acc[subPair[0]] = subPair[1]
464
465 return acc
466 }, {})
467 break
468 }
469 }
470
471 if (stringFormattingFn) key = stringFormattingFn(key)
472 return [key, value]
473}
474
475function isStream(obj) {
476 return (
477 obj &&
478 typeof obj !== 'string' &&
479 !Buffer.isBuffer(obj) &&
480 typeof obj.setEncoding === 'function'
481 )
482}
483
484/**
485 * Converts the arguments from the various signatures of http[s].request into a standard
486 * options object and an optional callback function.
487 *
488 * https://nodejs.org/api/http.html#http_http_request_url_options_callback
489 *
490 * Taken from the beginning of the native `ClientRequest`.
491 * https://github.com/nodejs/node/blob/908292cf1f551c614a733d858528ffb13fb3a524/lib/_http_client.js#L68
492 */
493function normalizeClientRequestArgs(input, options, cb) {
494 if (typeof input === 'string') {
495 input = urlToOptions(new url.URL(input))
496 } else if (input instanceof url.URL) {
497 input = urlToOptions(input)
498 } else {
499 cb = options
500 options = input
501 input = null
502 }
503
504 if (typeof options === 'function') {
505 cb = options
506 options = input || {}
507 } else {
508 options = Object.assign(input || {}, options)
509 }
510
511 return { options, callback: cb }
512}
513
514/**
515 * Utility function that converts a URL object into an ordinary
516 * options object as expected by the http.request and https.request APIs.
517 *
518 * This was copied from Node's source
519 * https://github.com/nodejs/node/blob/908292cf1f551c614a733d858528ffb13fb3a524/lib/internal/url.js#L1257
520 */
521function urlToOptions(url) {
522 const options = {
523 protocol: url.protocol,
524 hostname:
525 typeof url.hostname === 'string' && url.hostname.startsWith('[')
526 ? url.hostname.slice(1, -1)
527 : url.hostname,
528 hash: url.hash,
529 search: url.search,
530 pathname: url.pathname,
531 path: `${url.pathname}${url.search || ''}`,
532 href: url.href,
533 }
534 if (url.port !== '') {
535 options.port = Number(url.port)
536 }
537 if (url.username || url.password) {
538 options.auth = `${url.username}:${url.password}`
539 }
540 return options
541}
542
543/**
544 * Determines if request data matches the expected schema.
545 *
546 * Used for comparing decoded search parameters, request body JSON objects,
547 * and URL decoded request form bodies.
548 *
549 * Performs a general recursive strict comparision with two caveats:
550 * - The expected data can use regexp to compare values
551 * - JSON path notation and nested objects are considered equal
552 */
553const dataEqual = (expected, actual) =>
554 deepEqual(expand(expected), expand(actual))
555
556/**
557 * Converts flat objects whose keys use JSON path notation to nested objects.
558 *
559 * The input object is not mutated.
560 *
561 * @example
562 * { 'foo[bar][0]': 'baz' } -> { foo: { bar: [ 'baz' ] } }
563 */
564const expand = input =>
565 Object.entries(input).reduce((acc, [k, v]) => _.set(acc, k, v), {})
566
567/**
568 * Performs a recursive strict comparison between two values.
569 *
570 * Expected values or leaf nodes of expected object values that are RegExp use test() for comparison.
571 */
572function deepEqual(expected, actual) {
573 debug('deepEqual comparing', typeof expected, expected, typeof actual, actual)
574 if (expected instanceof RegExp) {
575 return expected.test(actual)
576 }
577
578 if (Array.isArray(expected) || _.isPlainObject(expected)) {
579 if (actual === undefined) {
580 return false
581 }
582
583 const expKeys = Object.keys(expected)
584 if (expKeys.length !== Object.keys(actual).length) {
585 return false
586 }
587
588 return expKeys.every(key => deepEqual(expected[key], actual[key]))
589 }
590
591 return expected === actual
592}
593
594const timeouts = []
595const intervals = []
596const immediates = []
597
598const wrapTimer = (timer, ids) => (...args) => {
599 const id = timer(...args)
600 ids.push(id)
601 return id
602}
603
604const setTimeout = wrapTimer(timers.setTimeout, timeouts)
605const setInterval = wrapTimer(timers.setInterval, intervals)
606const setImmediate = wrapTimer(timers.setImmediate, immediates)
607
608function clearTimer(clear, ids) {
609 while (ids.length) {
610 clear(ids.shift())
611 }
612}
613
614function removeAllTimers() {
615 clearTimer(clearTimeout, timeouts)
616 clearTimer(clearInterval, intervals)
617 clearTimer(clearImmediate, immediates)
618}
619
620exports.normalizeClientRequestArgs = normalizeClientRequestArgs
621exports.normalizeRequestOptions = normalizeRequestOptions
622exports.normalizeOrigin = normalizeOrigin
623exports.isUtf8Representable = isUtf8Representable
624exports.overrideRequests = overrideRequests
625exports.restoreOverriddenRequests = restoreOverriddenRequests
626exports.stringifyRequest = stringifyRequest
627exports.isContentEncoded = isContentEncoded
628exports.contentEncoding = contentEncoding
629exports.isJSONContent = isJSONContent
630exports.headersFieldNamesToLowerCase = headersFieldNamesToLowerCase
631exports.headersFieldsArrayToLowerCase = headersFieldsArrayToLowerCase
632exports.headersArrayToObject = headersArrayToObject
633exports.headersInputToRawArray = headersInputToRawArray
634exports.deleteHeadersField = deleteHeadersField
635exports.forEachHeader = forEachHeader
636exports.percentEncode = percentEncode
637exports.percentDecode = percentDecode
638exports.matchStringOrRegexp = matchStringOrRegexp
639exports.formatQueryValue = formatQueryValue
640exports.isStream = isStream
641exports.dataEqual = dataEqual
642exports.setTimeout = setTimeout
643exports.setInterval = setInterval
644exports.setImmediate = setImmediate
645exports.removeAllTimers = removeAllTimers