UNPKG

12.2 kBJavaScriptView Raw
1'use strict'
2
3const debug = require('debug')('nock.recorder')
4const querystring = require('querystring')
5const { inspect } = require('util')
6
7const common = require('./common')
8const { restoreOverriddenClientRequest } = require('./intercept')
9
10const SEPARATOR = '\n<<<<<<-- cut here -->>>>>>\n'
11let recordingInProgress = false
12let outputs = []
13
14function getScope(options) {
15 const { proto, host, port } = common.normalizeRequestOptions(options)
16 return common.normalizeOrigin(proto, host, port)
17}
18
19function getMethod(options) {
20 return options.method || 'GET'
21}
22
23function getBodyFromChunks(chunks, headers) {
24 // If we have headers and there is content-encoding it means that the body
25 // shouldn't be merged but instead persisted as an array of hex strings so
26 // that the response chunks can be mocked one by one.
27 if (headers && common.isContentEncoded(headers)) {
28 return {
29 body: chunks.map(chunk => chunk.toString('hex')),
30 }
31 }
32
33 const mergedBuffer = Buffer.concat(chunks)
34
35 // The merged buffer can be one of three things:
36 // 1. A UTF-8-representable string buffer which represents a JSON object.
37 // 2. A UTF-8-representable buffer which doesn't represent a JSON object.
38 // 3. A non-UTF-8-representable buffer which then has to be recorded as a hex string.
39 const isUtf8Representable = common.isUtf8Representable(mergedBuffer)
40 if (isUtf8Representable) {
41 const maybeStringifiedJson = mergedBuffer.toString('utf8')
42 try {
43 return {
44 isUtf8Representable,
45 body: JSON.parse(maybeStringifiedJson),
46 }
47 } catch (err) {
48 return {
49 isUtf8Representable,
50 body: maybeStringifiedJson,
51 }
52 }
53 } else {
54 return {
55 isUtf8Representable,
56 body: mergedBuffer.toString('hex'),
57 }
58 }
59}
60
61function generateRequestAndResponseObject({
62 req,
63 bodyChunks,
64 options,
65 res,
66 dataChunks,
67 reqheaders,
68}) {
69 const { body, isUtf8Representable } = getBodyFromChunks(
70 dataChunks,
71 res.headers
72 )
73 options.path = req.path
74
75 return {
76 scope: getScope(options),
77 method: getMethod(options),
78 path: options.path,
79 // Is it deliberate that `getBodyFromChunks()` is called a second time?
80 body: getBodyFromChunks(bodyChunks).body,
81 status: res.statusCode,
82 response: body,
83 rawHeaders: res.rawHeaders,
84 reqheaders: reqheaders || undefined,
85 // When content-encoding is enabled, isUtf8Representable is `undefined`,
86 // so we explicitly check for `false`.
87 responseIsBinary: isUtf8Representable === false,
88 }
89}
90
91function generateRequestAndResponse({
92 req,
93 bodyChunks,
94 options,
95 res,
96 dataChunks,
97 reqheaders,
98}) {
99 const requestBody = getBodyFromChunks(bodyChunks).body
100 const responseBody = getBodyFromChunks(dataChunks, res.headers).body
101
102 // Remove any query params from options.path so they can be added in the query() function
103 let { path } = options
104 const queryIndex = req.path.indexOf('?')
105 let queryObj = {}
106 if (queryIndex !== -1) {
107 // Remove the query from the path
108 path = path.substring(0, queryIndex)
109
110 const queryStr = req.path.slice(queryIndex + 1)
111 queryObj = querystring.parse(queryStr)
112 }
113
114 // Escape any single quotes in the path as the output uses them
115 path = path.replace(/'/g, `\\'`)
116
117 // Always encode the query parameters when recording.
118 const encodedQueryObj = {}
119 for (const key in queryObj) {
120 const formattedPair = common.formatQueryValue(
121 key,
122 queryObj[key],
123 common.percentEncode
124 )
125 encodedQueryObj[formattedPair[0]] = formattedPair[1]
126 }
127
128 const lines = []
129
130 // We want a leading newline.
131 lines.push('')
132
133 const scope = getScope(options)
134 lines.push(`nock('${scope}', {"encodedQueryParams":true})`)
135
136 const methodName = getMethod(options).toLowerCase()
137 if (requestBody) {
138 lines.push(` .${methodName}('${path}', ${JSON.stringify(requestBody)})`)
139 } else {
140 lines.push(` .${methodName}('${path}')`)
141 }
142
143 Object.entries(reqheaders || {}).forEach(([fieldName, fieldValue]) => {
144 const safeName = JSON.stringify(fieldName)
145 const safeValue = JSON.stringify(fieldValue)
146 lines.push(` .matchHeader(${safeName}, ${safeValue})`)
147 })
148
149 if (queryIndex !== -1) {
150 lines.push(` .query(${JSON.stringify(encodedQueryObj)})`)
151 }
152
153 const statusCode = res.statusCode.toString()
154 const stringifiedResponseBody = JSON.stringify(responseBody)
155 const headers = inspect(res.rawHeaders)
156 lines.push(` .reply(${statusCode}, ${stringifiedResponseBody}, ${headers});`)
157
158 return lines.join('\n')
159}
160
161// This module variable is used to identify a unique recording ID in order to skip
162// spurious requests that sometimes happen. This problem has been, so far,
163// exclusively detected in nock's unit testing where 'checks if callback is specified'
164// interferes with other tests as its t.end() is invoked without waiting for request
165// to finish (which is the point of the test).
166let currentRecordingId = 0
167
168const defaultRecordOptions = {
169 dont_print: false,
170 enable_reqheaders_recording: false,
171 logging: console.log,
172 output_objects: false,
173 use_separator: true,
174}
175
176function record(recOptions) {
177 // Trying to start recording with recording already in progress implies an error
178 // in the recording configuration (double recording makes no sense and used to lead
179 // to duplicates in output)
180 if (recordingInProgress) {
181 throw new Error('Nock recording already in progress')
182 }
183
184 recordingInProgress = true
185
186 // Set the new current recording ID and capture its value in this instance of record().
187 currentRecordingId = currentRecordingId + 1
188 const thisRecordingId = currentRecordingId
189
190 // Originally the parameter was a dont_print boolean flag.
191 // To keep the existing code compatible we take that case into account.
192 if (typeof recOptions === 'boolean') {
193 recOptions = { dont_print: recOptions }
194 }
195
196 recOptions = { ...defaultRecordOptions, ...recOptions }
197
198 debug('start recording', thisRecordingId, recOptions)
199
200 const {
201 dont_print: dontPrint,
202 enable_reqheaders_recording: enableReqHeadersRecording,
203 logging,
204 output_objects: outputObjects,
205 use_separator: useSeparator,
206 } = recOptions
207
208 debug(thisRecordingId, 'restoring overridden requests before new overrides')
209 // To preserve backward compatibility (starting recording wasn't throwing if nock was already active)
210 // we restore any requests that may have been overridden by other parts of nock (e.g. intercept)
211 // NOTE: This is hacky as hell but it keeps the backward compatibility *and* allows correct
212 // behavior in the face of other modules also overriding ClientRequest.
213 common.restoreOverriddenRequests()
214 // We restore ClientRequest as it messes with recording of modules that also override ClientRequest (e.g. xhr2)
215 restoreOverriddenClientRequest()
216
217 // We override the requests so that we can save information on them before executing.
218 common.overrideRequests(function (proto, overriddenRequest, rawArgs) {
219 const { options, callback } = common.normalizeClientRequestArgs(...rawArgs)
220 const bodyChunks = []
221
222 // Node 0.11 https.request calls http.request -- don't want to record things
223 // twice.
224 /* istanbul ignore if */
225 if (options._recording) {
226 return overriddenRequest(options, callback)
227 }
228 options._recording = true
229
230 const req = overriddenRequest(options, function (res) {
231 debug(thisRecordingId, 'intercepting', proto, 'request to record')
232
233 // We put our 'end' listener to the front of the listener array.
234 res.once('end', function () {
235 debug(thisRecordingId, proto, 'intercepted request ended')
236
237 let reqheaders
238 // Ignore request headers completely unless it was explicitly enabled by the user (see README)
239 if (enableReqHeadersRecording) {
240 // We never record user-agent headers as they are worse than useless -
241 // they actually make testing more difficult without providing any benefit (see README)
242 reqheaders = req.getHeaders()
243 common.deleteHeadersField(reqheaders, 'user-agent')
244 }
245
246 const generateFn = outputObjects
247 ? generateRequestAndResponseObject
248 : generateRequestAndResponse
249 let out = generateFn({
250 req,
251 bodyChunks,
252 options,
253 res,
254 dataChunks,
255 reqheaders,
256 })
257
258 debug('out:', out)
259
260 // Check that the request was made during the current recording.
261 // If it hasn't then skip it. There is no other simple way to handle
262 // this as it depends on the timing of requests and responses. Throwing
263 // will make some recordings/unit tests fail randomly depending on how
264 // fast/slow the response arrived.
265 // If you are seeing this error then you need to make sure that all
266 // the requests made during a single recording session finish before
267 // ending the same recording session.
268 if (thisRecordingId !== currentRecordingId) {
269 debug('skipping recording of an out-of-order request', out)
270 return
271 }
272
273 outputs.push(out)
274
275 if (!dontPrint) {
276 if (useSeparator) {
277 if (typeof out !== 'string') {
278 out = JSON.stringify(out, null, 2)
279 }
280 logging(SEPARATOR + out + SEPARATOR)
281 } else {
282 logging(out)
283 }
284 }
285 })
286
287 let encoding
288 // We need to be aware of changes to the stream's encoding so that we
289 // don't accidentally mangle the data.
290 const { setEncoding } = res
291 res.setEncoding = function (newEncoding) {
292 encoding = newEncoding
293 return setEncoding.apply(this, arguments)
294 }
295
296 const dataChunks = []
297 // Replace res.push with our own implementation that stores chunks
298 const origResPush = res.push
299 res.push = function (data) {
300 if (data) {
301 if (encoding) {
302 data = Buffer.from(data, encoding)
303 }
304 dataChunks.push(data)
305 }
306
307 return origResPush.call(res, data)
308 }
309
310 if (callback) {
311 callback(res, options, callback)
312 } else {
313 res.resume()
314 }
315
316 debug('finished setting up intercepting')
317
318 // We override both the http and the https modules; when we are
319 // serializing the request, we need to know which was called.
320 // By stuffing the state, we can make sure that nock records
321 // the intended protocol.
322 if (proto === 'https') {
323 options.proto = 'https'
324 }
325 })
326
327 const recordChunk = (chunk, encoding) => {
328 debug(thisRecordingId, 'new', proto, 'body chunk')
329 if (!Buffer.isBuffer(chunk)) {
330 chunk = Buffer.from(chunk, encoding)
331 }
332 bodyChunks.push(chunk)
333 }
334
335 const oldWrite = req.write
336 req.write = function (chunk, encoding) {
337 if (typeof chunk !== 'undefined') {
338 recordChunk(chunk, encoding)
339 oldWrite.apply(req, arguments)
340 } else {
341 throw new Error('Data was undefined.')
342 }
343 }
344
345 // Starting in Node 8, `OutgoingMessage.end()` directly calls an internal
346 // `write_` function instead of proxying to the public
347 // `OutgoingMessage.write()` method, so we have to wrap `end` too.
348 const oldEnd = req.end
349 req.end = function (chunk, encoding, callback) {
350 debug('req.end')
351 if (typeof chunk === 'function') {
352 callback = chunk
353 chunk = null
354 } else if (typeof encoding === 'function') {
355 callback = encoding
356 encoding = null
357 }
358
359 if (chunk) {
360 recordChunk(chunk, encoding)
361 }
362 oldEnd.call(req, chunk, encoding, callback)
363 }
364
365 return req
366 })
367}
368
369// Restore *all* the overridden http/https modules' properties.
370function restore() {
371 debug(
372 currentRecordingId,
373 'restoring all the overridden http/https properties'
374 )
375
376 common.restoreOverriddenRequests()
377 restoreOverriddenClientRequest()
378 recordingInProgress = false
379}
380
381function clear() {
382 outputs = []
383}
384
385module.exports = {
386 record,
387 outputs: () => outputs,
388 restore,
389 clear,
390}