/
index.js
336 lines (272 loc) · 9.63 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
import http from 'http';
import https from 'https';
import { URL } from 'url';
import { Readable as ReadableStream } from 'stream';
import nock from 'nock';
import {
normalizeClientRequestArgs,
isUtf8Representable,
isContentEncoded
} from 'nock/lib/common';
import Adapter from '@pollyjs/adapter';
import { HTTP_METHODS } from '@pollyjs/utils';
import getUrlFromOptions from './utils/get-url-from-options';
import mergeChunks from './utils/merge-chunks';
import urlToOptions from './utils/url-to-options';
const IS_STUBBED = Symbol();
const ABORT_HANDLER = Symbol();
const REQUEST_ARGUMENTS = new WeakMap();
// nock begins to intercept network requests on import which is not the
// behavior we want, so restore the original behavior right away.
nock.restore();
export default class HttpAdapter extends Adapter {
static get id() {
return 'node-http';
}
static get name() {
// NOTE: deprecated in 4.1.0 but proxying since it's possible "core" is behind
// and therefore still referencing `name`. Remove in 5.0.0
return this.id;
}
onConnect() {
this.assert(
'Running concurrent node-http adapters is unsupported, stop any running Polly instances.',
!http.ClientRequest[IS_STUBBED]
);
this.assert(
'Running nock concurrently with the node-http adapter is unsupported. Run nock.restore() before connecting to this adapter.',
!nock.isActive()
);
this.NativeClientRequest = http.ClientRequest;
this.setupNock();
// Patch methods overridden by nock to add some missing functionality
this.patchOverriddenMethods();
}
onDisconnect() {
this.unpatchOverriddenMethods();
nock.cleanAll();
nock.restore();
this.NativeClientRequest = null;
}
setupNock() {
const adapter = this;
// Make sure there aren't any other interceptors defined
nock.cleanAll();
// Create our interceptor that will match all hosts
const interceptor = nock(/.*/).persist();
HTTP_METHODS.forEach((m) => {
// Add an intercept for each supported HTTP method that will match all paths
interceptor.intercept(/.*/, m).reply(function (_, _body, respond) {
const { req, method } = this;
const { headers } = req;
const parsedArguments = normalizeClientRequestArgs(
...REQUEST_ARGUMENTS.get(req)
);
const url = getUrlFromOptions(parsedArguments.options);
const requestBodyBuffer = Buffer.concat(req.requestBodyBuffers);
const body = isUtf8Representable(requestBodyBuffer)
? requestBodyBuffer.toString('utf8')
: requestBodyBuffer;
adapter.handleRequest({
url,
method,
headers,
body,
requestArguments: { req, body, respond, parsedArguments }
});
});
});
// Activate nock so it can start to intercept all outgoing requests
nock.activate();
}
patchOverriddenMethods() {
const modules = { http, https };
const { ClientRequest } = http;
// Patch the already overridden ClientRequest class so we can get
// access to the original arguments and use them when creating the
// passthrough request.
http.ClientRequest = function _ClientRequest() {
const req = new ClientRequest(...arguments);
REQUEST_ARGUMENTS.set(req, [...arguments]);
return req;
};
// Add an IS_STUBBED boolean so we can check on onConnect if we've already
// patched the necessary methods.
http.ClientRequest[IS_STUBBED] = true;
// Patch http.request, http.get, https.request, and https.get
// to set some default values which nock doesn't properly set.
Object.keys(modules).forEach((moduleName) => {
const module = modules[moduleName];
const { request, get, globalAgent } = module;
this[moduleName] = {
get,
request
};
function parseArgs() {
const args = normalizeClientRequestArgs(...arguments);
if (moduleName === 'https') {
args.options = {
...{ port: 443, protocol: 'https:', _defaultAgent: globalAgent },
...args.options
};
} else {
args.options = {
...{ port: 80, protocol: 'http:' },
...args.options
};
}
return args;
}
module.request = function _request() {
const { options, callback } = parseArgs(...arguments);
return request(options, callback);
};
module.get = function _get() {
const { options, callback } = parseArgs(...arguments);
return get(options, callback);
};
});
}
unpatchOverriddenMethods() {
const modules = { http, https };
Object.keys(modules).forEach((moduleName) => {
const module = modules[moduleName];
module.request = this[moduleName].request;
module.get = this[moduleName].get;
this[moduleName] = undefined;
});
}
onRequest(pollyRequest) {
const { req } = pollyRequest.requestArguments;
if (req.aborted) {
pollyRequest.abort();
} else {
pollyRequest[ABORT_HANDLER] = () => pollyRequest.abort();
req.once('abort', pollyRequest[ABORT_HANDLER]);
}
}
async passthroughRequest(pollyRequest) {
const { parsedArguments } = pollyRequest.requestArguments;
const { method, headers, body } = pollyRequest;
const { options } = parsedArguments;
const request = new this.NativeClientRequest({
...options,
method,
headers: { ...headers },
...urlToOptions(new URL(pollyRequest.url))
});
const chunks = this.getChunksFromBody(body, headers);
const responsePromise = new Promise((resolve, reject) => {
request.once('response', resolve);
request.once('error', reject);
request.once('timeout', reject);
});
// Write the request body
chunks.forEach((chunk) => request.write(chunk));
request.end();
const response = await responsePromise;
const responseBody = await new Promise((resolve, reject) => {
const chunks = [];
response.on('data', (chunk) => chunks.push(chunk));
response.once('end', () =>
resolve(this.getBodyFromChunks(chunks, response.headers))
);
response.once('error', reject);
});
return {
headers: response.headers,
statusCode: response.statusCode,
body: responseBody.body,
encoding: responseBody.encoding
};
}
async respondToRequest(pollyRequest, error) {
const { req, respond } = pollyRequest.requestArguments;
const { statusCode, body, headers, encoding } = pollyRequest.response;
if (pollyRequest[ABORT_HANDLER]) {
req.off('abort', pollyRequest[ABORT_HANDLER]);
}
if (pollyRequest.aborted) {
// Even if the request has been aborted, we need to respond to the nock
// request in order to resolve its awaiting promise.
respond(null, [0, undefined, {}]);
return;
}
if (error) {
// If an error was received then forward it over to nock so it can
// correctly handle it.
respond(error);
return;
}
const chunks = this.getChunksFromBody(body, headers, encoding);
const stream = new ReadableStream();
// Expose the response data as a stream of chunks since
// it could contain encoded data which is needed
// to be pushed to the response chunk by chunk.
chunks.forEach((chunk) => stream.push(chunk));
stream.push(null);
// Create a promise that will resolve once the request
// has been completed (including errored or aborted). This is needed so
// that the deferred promise used by `polly.flush()` doesn't resolve before
// the response was actually received.
const requestFinishedPromise = new Promise((resolve) => {
if (req.aborted) {
resolve();
} else {
req.once('response', resolve);
req.once('abort', resolve);
req.once('error', resolve);
}
});
respond(null, [statusCode, stream, headers]);
await requestFinishedPromise;
}
getBodyFromChunks(chunks, headers) {
// If content-encoding is set in the header then the body/content
// should not be concatenated. Instead, the chunks should
// be preserved as-is so that each chunk can be mocked individually
if (isContentEncoded(headers)) {
const encodedChunks = chunks.map((chunk) => {
if (!Buffer.isBuffer(chunk)) {
this.assert(
'content-encoded responses must all be binary buffers',
typeof chunk === 'string'
);
chunk = Buffer.from(chunk);
}
return chunk.toString('base64');
});
return {
encoding: 'base64',
body: JSON.stringify(encodedChunks)
};
}
const buffer = mergeChunks(chunks);
const isBinaryBuffer = !isUtf8Representable(buffer);
// The merged buffer can be one of two things:
// 1. A binary buffer which then has to be recorded as a base64 string.
// 2. A string buffer.
return {
encoding: isBinaryBuffer ? 'base64' : undefined,
body: buffer.toString(isBinaryBuffer ? 'base64' : 'utf8')
};
}
getChunksFromBody(body, headers, encoding) {
if (!body) {
return [];
}
if (Buffer.isBuffer(body)) {
return [body];
}
// If content-encoding is set in the header then the body/content
// is as an array of base64 strings
if (isContentEncoded(headers)) {
const encodedChunks = JSON.parse(body);
return encodedChunks.map((chunk) => Buffer.from(chunk, encoding));
}
// The body can be one of two things:
// 1. A base64 string which then means its binary data.
// 2. A utf8 string which means a regular string.
return [Buffer.from(body, encoding ? encoding : 'utf8')];
}
}