nock 14.0.3 → 15.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/index.js +2 -0
- package/lib/common.js +73 -146
- package/lib/global_emitter.js +1 -1
- package/lib/handle-request.js +107 -0
- package/lib/intercept.js +43 -206
- package/lib/interceptor.js +48 -85
- package/lib/interceptors/builtin.js +63 -0
- package/lib/interceptors/undici.js +75 -0
- package/lib/match_body.js +10 -9
- package/lib/playback_interceptor.js +146 -179
- package/lib/recorder.js +72 -86
- package/lib/utils/node/index.js +33 -0
- package/package.json +7 -6
- package/types/index.d.ts +17 -36
- package/lib/intercepted_request_router.js +0 -343
- package/lib/socket.js +0 -108
package/README.md
CHANGED
package/index.js
CHANGED
|
@@ -17,6 +17,7 @@ const {
|
|
|
17
17
|
} = require('./lib/intercept')
|
|
18
18
|
const recorder = require('./lib/recorder')
|
|
19
19
|
const { Scope, load, loadDefs, define } = require('./lib/scope')
|
|
20
|
+
const { getDecompressedGetBody } = require('./lib/utils/node')
|
|
20
21
|
|
|
21
22
|
module.exports = (basePath, options) => new Scope(basePath, options)
|
|
22
23
|
|
|
@@ -42,6 +43,7 @@ Object.assign(module.exports, {
|
|
|
42
43
|
},
|
|
43
44
|
restore: recorder.restore,
|
|
44
45
|
back,
|
|
46
|
+
getDecompressedGetBody,
|
|
45
47
|
})
|
|
46
48
|
|
|
47
49
|
// We always activate Nock on import, overriding the globals.
|
package/lib/common.js
CHANGED
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
const { common: debug } = require('./debug')
|
|
4
4
|
const timers = require('timers')
|
|
5
|
-
const url = require('url')
|
|
6
5
|
const util = require('util')
|
|
7
|
-
const
|
|
6
|
+
const zlib = require('zlib')
|
|
8
7
|
|
|
9
8
|
/**
|
|
10
9
|
* Normalizes the request options so that it always has `host` property.
|
|
@@ -42,49 +41,48 @@ function normalizeRequestOptions(options) {
|
|
|
42
41
|
* Returns true if the data contained in buffer can be reconstructed
|
|
43
42
|
* from its utf8 representation.
|
|
44
43
|
*
|
|
45
|
-
* @param {
|
|
44
|
+
* @param {ArrayBuffer} buffer
|
|
46
45
|
* @returns {boolean}
|
|
47
46
|
*/
|
|
48
47
|
function isUtf8Representable(buffer) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
try {
|
|
49
|
+
new TextDecoder('utf8', { fatal: true }).decode(buffer)
|
|
50
|
+
return true
|
|
51
|
+
} catch {
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
/**
|
|
55
57
|
* In WHATWG URL vernacular, this returns the origin portion of a URL.
|
|
56
58
|
* However, the port is not included if it's standard and not already present on the host.
|
|
59
|
+
*
|
|
60
|
+
* @param {URL} url
|
|
57
61
|
*/
|
|
58
|
-
function normalizeOrigin(
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
function normalizeOrigin(url) {
|
|
63
|
+
// Remove brackets from hostname if IPV6
|
|
64
|
+
const normalizedOrigin = url.hostname.startsWith('[')
|
|
65
|
+
? `${url.protocol}//${url.hostname.slice(1, -1)}${url.port ? `:${url.port}` : ''}`
|
|
66
|
+
: url.origin
|
|
67
|
+
if (url.port) {
|
|
68
|
+
return normalizedOrigin
|
|
69
|
+
} else {
|
|
70
|
+
return normalizedOrigin + (url.protocol === 'http:' ? ':80' : ':443')
|
|
71
|
+
}
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
/**
|
|
69
75
|
* Get high level information about request as string
|
|
70
|
-
* @param {
|
|
71
|
-
* @param {string} options.method
|
|
72
|
-
* @param {number|string} options.port
|
|
73
|
-
* @param {string} options.proto Set internally. always http or https
|
|
74
|
-
* @param {string} options.hostname
|
|
75
|
-
* @param {string} options.path
|
|
76
|
-
* @param {Object} options.headers
|
|
76
|
+
* @param {Request} request
|
|
77
77
|
* @param {string} body
|
|
78
|
-
* @return {string}
|
|
79
78
|
*/
|
|
80
|
-
function stringifyRequest(
|
|
81
|
-
const
|
|
82
|
-
const origin = normalizeOrigin(options.proto, options.hostname, port)
|
|
79
|
+
function stringifyRequest(request, body) {
|
|
80
|
+
const url = new URL(request.url)
|
|
83
81
|
|
|
84
82
|
const log = {
|
|
85
|
-
method,
|
|
86
|
-
url: `${origin}${
|
|
87
|
-
headers:
|
|
83
|
+
method: request.method,
|
|
84
|
+
url: `${url.origin}${url.pathname}`,
|
|
85
|
+
headers: Object.fromEntries(request.headers.entries()),
|
|
88
86
|
}
|
|
89
87
|
|
|
90
88
|
if (body) {
|
|
@@ -99,14 +97,22 @@ function isContentEncoded(headers) {
|
|
|
99
97
|
return typeof contentEncoding === 'string' && contentEncoding !== ''
|
|
100
98
|
}
|
|
101
99
|
|
|
100
|
+
/**
|
|
101
|
+
* @param {Headers} headers
|
|
102
|
+
* @param {'gzip' | 'deflate'} encoder
|
|
103
|
+
* @returns
|
|
104
|
+
*/
|
|
102
105
|
function contentEncoding(headers, encoder) {
|
|
103
|
-
const contentEncoding = headers
|
|
104
|
-
return contentEncoding
|
|
106
|
+
const contentEncoding = headers.get('content-encoding')
|
|
107
|
+
return contentEncoding?.toString() === encoder
|
|
105
108
|
}
|
|
106
109
|
|
|
110
|
+
/**
|
|
111
|
+
* @param {Headers} headers
|
|
112
|
+
*/
|
|
107
113
|
function isJSONContent(headers) {
|
|
108
114
|
// https://tools.ietf.org/html/rfc8259
|
|
109
|
-
const contentType = String(headers
|
|
115
|
+
const contentType = String(headers.get('content-type') || '').toLowerCase()
|
|
110
116
|
return contentType.startsWith('application/json')
|
|
111
117
|
}
|
|
112
118
|
|
|
@@ -409,65 +415,6 @@ function isStream(obj) {
|
|
|
409
415
|
)
|
|
410
416
|
}
|
|
411
417
|
|
|
412
|
-
/**
|
|
413
|
-
* Converts the arguments from the various signatures of http[s].request into a standard
|
|
414
|
-
* options object and an optional callback function.
|
|
415
|
-
*
|
|
416
|
-
* https://nodejs.org/api/http.html#http_http_request_url_options_callback
|
|
417
|
-
*
|
|
418
|
-
* Taken from the beginning of the native `ClientRequest`.
|
|
419
|
-
* https://github.com/nodejs/node/blob/908292cf1f551c614a733d858528ffb13fb3a524/lib/_http_client.js#L68
|
|
420
|
-
*/
|
|
421
|
-
function normalizeClientRequestArgs(input, options, cb) {
|
|
422
|
-
if (typeof input === 'string') {
|
|
423
|
-
input = urlToOptions(new url.URL(input))
|
|
424
|
-
} else if (input instanceof url.URL) {
|
|
425
|
-
input = urlToOptions(input)
|
|
426
|
-
} else {
|
|
427
|
-
cb = options
|
|
428
|
-
options = input
|
|
429
|
-
input = null
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
if (typeof options === 'function') {
|
|
433
|
-
cb = options
|
|
434
|
-
options = input || {}
|
|
435
|
-
} else {
|
|
436
|
-
options = Object.assign(input || {}, options)
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
return { options, callback: cb }
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
/**
|
|
443
|
-
* Utility function that converts a URL object into an ordinary
|
|
444
|
-
* options object as expected by the http.request and https.request APIs.
|
|
445
|
-
*
|
|
446
|
-
* This was copied from Node's source
|
|
447
|
-
* https://github.com/nodejs/node/blob/908292cf1f551c614a733d858528ffb13fb3a524/lib/internal/url.js#L1257
|
|
448
|
-
*/
|
|
449
|
-
function urlToOptions(url) {
|
|
450
|
-
const options = {
|
|
451
|
-
protocol: url.protocol,
|
|
452
|
-
hostname:
|
|
453
|
-
typeof url.hostname === 'string' && url.hostname.startsWith('[')
|
|
454
|
-
? url.hostname.slice(1, -1)
|
|
455
|
-
: url.hostname,
|
|
456
|
-
hash: url.hash,
|
|
457
|
-
search: url.search,
|
|
458
|
-
pathname: url.pathname,
|
|
459
|
-
path: `${url.pathname}${url.search || ''}`,
|
|
460
|
-
href: url.href,
|
|
461
|
-
}
|
|
462
|
-
if (url.port !== '') {
|
|
463
|
-
options.port = Number(url.port)
|
|
464
|
-
}
|
|
465
|
-
if (url.username || url.password) {
|
|
466
|
-
options.auth = `${url.username}:${url.password}`
|
|
467
|
-
}
|
|
468
|
-
return options
|
|
469
|
-
}
|
|
470
|
-
|
|
471
418
|
/**
|
|
472
419
|
* Determines if request data matches the expected schema.
|
|
473
420
|
*
|
|
@@ -551,59 +498,6 @@ function removeAllTimers() {
|
|
|
551
498
|
clearTimer(clearImmediate, immediates)
|
|
552
499
|
}
|
|
553
500
|
|
|
554
|
-
/**
|
|
555
|
-
* Check if the Client Request has been cancelled.
|
|
556
|
-
*
|
|
557
|
-
* Until Node 14 is the minimum, we need to look at both flags to see if the request has been cancelled.
|
|
558
|
-
* The two flags have the same purpose, but the Node maintainers are migrating from `abort(ed)` to
|
|
559
|
-
* `destroy(ed)` terminology, to be more consistent with `stream.Writable`.
|
|
560
|
-
* In Node 14.x+, Calling `abort()` will set both `aborted` and `destroyed` to true, however,
|
|
561
|
-
* calling `destroy()` will only set `destroyed` to true.
|
|
562
|
-
* Falling back on checking if the socket is destroyed to cover the case of Node <14.x where
|
|
563
|
-
* `destroy()` is called, but `destroyed` is undefined.
|
|
564
|
-
*
|
|
565
|
-
* Node Client Request history:
|
|
566
|
-
* - `request.abort()`: Added in: v0.3.8, Deprecated since: v14.1.0, v13.14.0
|
|
567
|
-
* - `request.aborted`: Added in: v0.11.14, Became a boolean instead of a timestamp: v11.0.0, Not deprecated (yet)
|
|
568
|
-
* - `request.destroy()`: Added in: v0.3.0
|
|
569
|
-
* - `request.destroyed`: Added in: v14.1.0, v13.14.0
|
|
570
|
-
*
|
|
571
|
-
* @param {ClientRequest} req
|
|
572
|
-
* @returns {boolean}
|
|
573
|
-
*/
|
|
574
|
-
function isRequestDestroyed(req) {
|
|
575
|
-
return !!(
|
|
576
|
-
req.destroyed === true ||
|
|
577
|
-
req.aborted ||
|
|
578
|
-
(req.socket && req.socket.destroyed)
|
|
579
|
-
)
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
/**
|
|
583
|
-
* @param {Request} request
|
|
584
|
-
*/
|
|
585
|
-
function convertFetchRequestToClientRequest(request) {
|
|
586
|
-
const url = new URL(request.url)
|
|
587
|
-
const options = {
|
|
588
|
-
...urlToOptions(url),
|
|
589
|
-
method: request.method,
|
|
590
|
-
host: url.hostname,
|
|
591
|
-
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
|
592
|
-
path: url.pathname + url.search,
|
|
593
|
-
proto: url.protocol.slice(0, -1),
|
|
594
|
-
headers: Object.fromEntries(request.headers.entries()),
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
// By default, Node adds a host header, but for maximum backward compatibility, we are now removing it.
|
|
598
|
-
// However, we need to consider leaving the header and fixing the tests.
|
|
599
|
-
if (options.headers.host === options.host) {
|
|
600
|
-
const { host, ...restHeaders } = options.headers
|
|
601
|
-
options.headers = restHeaders
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
return new http.ClientRequest(options)
|
|
605
|
-
}
|
|
606
|
-
|
|
607
501
|
/**
|
|
608
502
|
* Returns true if the given value is a plain object and not an Array.
|
|
609
503
|
* @param {*} value
|
|
@@ -690,6 +584,40 @@ const expand = input => {
|
|
|
690
584
|
return result
|
|
691
585
|
}
|
|
692
586
|
|
|
587
|
+
/**
|
|
588
|
+
* @param {ArrayBuffer} buffer
|
|
589
|
+
* @param {string} contentEncoding
|
|
590
|
+
*/
|
|
591
|
+
function decompressRequestBody(buffer, contentEncoding) {
|
|
592
|
+
const encodings = contentEncoding
|
|
593
|
+
.toLowerCase()
|
|
594
|
+
.split(',')
|
|
595
|
+
.map(coding => coding.trim())
|
|
596
|
+
|
|
597
|
+
for (const encoding of encodings) {
|
|
598
|
+
if (encoding === 'gzip') {
|
|
599
|
+
return zlib.gunzipSync(buffer)
|
|
600
|
+
} else if (encoding === 'deflate') {
|
|
601
|
+
return zlib.inflateSync(buffer)
|
|
602
|
+
} else if (encoding === 'br') {
|
|
603
|
+
return zlib.brotliDecompressSync(buffer)
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return buffer
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* @param {Headers} headers
|
|
612
|
+
*/
|
|
613
|
+
function convertHeadersToRaw(headers) {
|
|
614
|
+
const rawHeaders = []
|
|
615
|
+
for (const [name, value] of headers.entries()) {
|
|
616
|
+
rawHeaders.push(name, value)
|
|
617
|
+
}
|
|
618
|
+
return rawHeaders
|
|
619
|
+
}
|
|
620
|
+
|
|
693
621
|
module.exports = {
|
|
694
622
|
contentEncoding,
|
|
695
623
|
dataEqual,
|
|
@@ -704,11 +632,9 @@ module.exports = {
|
|
|
704
632
|
isContentEncoded,
|
|
705
633
|
isJSONContent,
|
|
706
634
|
isPlainObject,
|
|
707
|
-
isRequestDestroyed,
|
|
708
635
|
isStream,
|
|
709
636
|
isUtf8Representable,
|
|
710
637
|
matchStringOrRegexp,
|
|
711
|
-
normalizeClientRequestArgs,
|
|
712
638
|
normalizeOrigin,
|
|
713
639
|
normalizeRequestOptions,
|
|
714
640
|
percentDecode,
|
|
@@ -717,5 +643,6 @@ module.exports = {
|
|
|
717
643
|
setImmediate,
|
|
718
644
|
setTimeout,
|
|
719
645
|
stringifyRequest,
|
|
720
|
-
|
|
646
|
+
decompressRequestBody,
|
|
647
|
+
convertHeadersToRaw,
|
|
721
648
|
}
|
package/lib/global_emitter.js
CHANGED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { inherits } = require('node:util')
|
|
3
|
+
const globalEmitter = require('./global_emitter')
|
|
4
|
+
const common = require('./common')
|
|
5
|
+
const { playbackInterceptor } = require('./playback_interceptor')
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {Request} request
|
|
9
|
+
*/
|
|
10
|
+
async function handleRequest(request) {
|
|
11
|
+
const {
|
|
12
|
+
interceptorsFor,
|
|
13
|
+
isOn,
|
|
14
|
+
isEnabledForNetConnect,
|
|
15
|
+
} = require('./intercept')
|
|
16
|
+
const url = new URL(request.url)
|
|
17
|
+
const interceptors = interceptorsFor(url)
|
|
18
|
+
|
|
19
|
+
if (isOn() && interceptors) {
|
|
20
|
+
const matches = interceptors.some(interceptor =>
|
|
21
|
+
interceptor.matchOrigin(request),
|
|
22
|
+
)
|
|
23
|
+
const allowUnmocked = interceptors.some(
|
|
24
|
+
interceptor => interceptor.options.allowUnmocked,
|
|
25
|
+
)
|
|
26
|
+
if (!matches && allowUnmocked) {
|
|
27
|
+
globalEmitter.emit('no match', request)
|
|
28
|
+
} else {
|
|
29
|
+
const requestBodyBuffer = await request.clone().arrayBuffer()
|
|
30
|
+
// When request body is a binary buffer we internally use in its hexadecimal representation.
|
|
31
|
+
const requestBodyIsUtf8Representable =
|
|
32
|
+
common.isUtf8Representable(requestBodyBuffer)
|
|
33
|
+
const requestBodyString = Buffer.from(requestBodyBuffer).toString(
|
|
34
|
+
requestBodyIsUtf8Representable ? 'utf8' : 'hex',
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
const matchedInterceptor = interceptors.find(i =>
|
|
38
|
+
i.match(request, requestBodyString),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if (matchedInterceptor) {
|
|
42
|
+
matchedInterceptor.scope.logger(
|
|
43
|
+
'interceptor identified, starting mocking',
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
matchedInterceptor.markConsumed()
|
|
47
|
+
|
|
48
|
+
const response = await playbackInterceptor({
|
|
49
|
+
decompressedRequest: request,
|
|
50
|
+
requestBodyString,
|
|
51
|
+
interceptor: matchedInterceptor,
|
|
52
|
+
requestBodyIsUtf8Representable,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
return response
|
|
56
|
+
} else {
|
|
57
|
+
globalEmitter.emit('no match', request)
|
|
58
|
+
|
|
59
|
+
// Try to find a hostname match that allows unmocked.
|
|
60
|
+
const allowUnmocked = interceptors.some(
|
|
61
|
+
i => i.matchHostName(url.hostname) && i.options.allowUnmocked,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if (!allowUnmocked) {
|
|
65
|
+
const reqStr = common.stringifyRequest(request, requestBodyString)
|
|
66
|
+
const err = new Error(`Nock: No match for request ${reqStr}`)
|
|
67
|
+
err.code = 'ERR_NOCK_NO_MATCH'
|
|
68
|
+
err.statusCode = err.status = 404
|
|
69
|
+
throw err
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
globalEmitter.emit('no match', request)
|
|
75
|
+
// Remove http(s):// for backward compatibility until we decide this Error.
|
|
76
|
+
const normalizedUrl = common
|
|
77
|
+
.normalizeOrigin(url)
|
|
78
|
+
.replace(`${url.protocol}//`, '')
|
|
79
|
+
if (isOn() && !isEnabledForNetConnect(normalizedUrl)) {
|
|
80
|
+
throw new NetConnectNotAllowedError(normalizedUrl, url.pathname)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @name NetConnectNotAllowedError
|
|
87
|
+
* @private
|
|
88
|
+
* @desc Error trying to make a connection when disabled external access.
|
|
89
|
+
* @class
|
|
90
|
+
* @example
|
|
91
|
+
* nock.disableNetConnect();
|
|
92
|
+
* http.get('http://zombo.com');
|
|
93
|
+
* // throw NetConnectNotAllowedError
|
|
94
|
+
*/
|
|
95
|
+
function NetConnectNotAllowedError(host, path) {
|
|
96
|
+
Error.call(this)
|
|
97
|
+
|
|
98
|
+
this.name = 'NetConnectNotAllowedError'
|
|
99
|
+
this.code = 'ENETUNREACH'
|
|
100
|
+
this.message = `Nock: Disallowed net connect for "${host}${path}"`
|
|
101
|
+
|
|
102
|
+
Error.captureStackTrace(this, this.constructor)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
inherits(NetConnectNotAllowedError, Error)
|
|
106
|
+
|
|
107
|
+
module.exports = handleRequest
|