UNPKG

12.1 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 // Always encode the query parameters when recording.
114 const encodedQueryObj = {}
115 for (const key in queryObj) {
116 const formattedPair = common.formatQueryValue(
117 key,
118 queryObj[key],
119 common.percentEncode
120 )
121 encodedQueryObj[formattedPair[0]] = formattedPair[1]
122 }
123
124 const lines = []
125
126 // We want a leading newline.
127 lines.push('')
128
129 const scope = getScope(options)
130 lines.push(`nock('${scope}', {"encodedQueryParams":true})`)
131
132 const methodName = getMethod(options).toLowerCase()
133 if (requestBody) {
134 lines.push(` .${methodName}('${path}', ${JSON.stringify(requestBody)})`)
135 } else {
136 lines.push(` .${methodName}('${path}')`)
137 }
138
139 Object.entries(reqheaders || {}).forEach(([fieldName, fieldValue]) => {
140 const safeName = JSON.stringify(fieldName)
141 const safeValue = JSON.stringify(fieldValue)
142 lines.push(` .matchHeader(${safeName}, ${safeValue})`)
143 })
144
145 if (queryIndex !== -1) {
146 lines.push(` .query(${JSON.stringify(encodedQueryObj)})`)
147 }
148
149 const statusCode = res.statusCode.toString()
150 const stringifiedResponseBody = JSON.stringify(responseBody)
151 const headers = inspect(res.rawHeaders)
152 lines.push(` .reply(${statusCode}, ${stringifiedResponseBody}, ${headers});`)
153
154 return lines.join('\n')
155}
156
157// This module variable is used to identify a unique recording ID in order to skip
158// spurious requests that sometimes happen. This problem has been, so far,
159// exclusively detected in nock's unit testing where 'checks if callback is specified'
160// interferes with other tests as its t.end() is invoked without waiting for request
161// to finish (which is the point of the test).
162let currentRecordingId = 0
163
164const defaultRecordOptions = {
165 dont_print: false,
166 enable_reqheaders_recording: false,
167 logging: console.log,
168 output_objects: false,
169 use_separator: true,
170}
171
172function record(recOptions) {
173 // Trying to start recording with recording already in progress implies an error
174 // in the recording configuration (double recording makes no sense and used to lead
175 // to duplicates in output)
176 if (recordingInProgress) {
177 throw new Error('Nock recording already in progress')
178 }
179
180 recordingInProgress = true
181
182 // Set the new current recording ID and capture its value in this instance of record().
183 currentRecordingId = currentRecordingId + 1
184 const thisRecordingId = currentRecordingId
185
186 // Originally the parameter was a dont_print boolean flag.
187 // To keep the existing code compatible we take that case into account.
188 if (typeof recOptions === 'boolean') {
189 recOptions = { dont_print: recOptions }
190 }
191
192 recOptions = { ...defaultRecordOptions, ...recOptions }
193
194 debug('start recording', thisRecordingId, recOptions)
195
196 const {
197 dont_print: dontPrint,
198 enable_reqheaders_recording: enableReqHeadersRecording,
199 logging,
200 output_objects: outputObjects,
201 use_separator: useSeparator,
202 } = recOptions
203
204 debug(thisRecordingId, 'restoring overridden requests before new overrides')
205 // To preserve backward compatibility (starting recording wasn't throwing if nock was already active)
206 // we restore any requests that may have been overridden by other parts of nock (e.g. intercept)
207 // NOTE: This is hacky as hell but it keeps the backward compatibility *and* allows correct
208 // behavior in the face of other modules also overriding ClientRequest.
209 common.restoreOverriddenRequests()
210 // We restore ClientRequest as it messes with recording of modules that also override ClientRequest (e.g. xhr2)
211 restoreOverriddenClientRequest()
212
213 // We override the requests so that we can save information on them before executing.
214 common.overrideRequests(function(proto, overriddenRequest, rawArgs) {
215 const { options, callback } = common.normalizeClientRequestArgs(...rawArgs)
216 const bodyChunks = []
217
218 // Node 0.11 https.request calls http.request -- don't want to record things
219 // twice.
220 /* istanbul ignore if */
221 if (options._recording) {
222 return overriddenRequest(options, callback)
223 }
224 options._recording = true
225
226 const req = overriddenRequest(options, function(res) {
227 debug(thisRecordingId, 'intercepting', proto, 'request to record')
228
229 // We put our 'end' listener to the front of the listener array.
230 res.once('end', function() {
231 debug(thisRecordingId, proto, 'intercepted request ended')
232
233 let reqheaders
234 // Ignore request headers completely unless it was explicitly enabled by the user (see README)
235 if (enableReqHeadersRecording) {
236 // We never record user-agent headers as they are worse than useless -
237 // they actually make testing more difficult without providing any benefit (see README)
238 reqheaders = req.getHeaders()
239 common.deleteHeadersField(reqheaders, 'user-agent')
240 }
241
242 const generateFn = outputObjects
243 ? generateRequestAndResponseObject
244 : generateRequestAndResponse
245 let out = generateFn({
246 req,
247 bodyChunks,
248 options,
249 res,
250 dataChunks,
251 reqheaders,
252 })
253
254 debug('out:', out)
255
256 // Check that the request was made during the current recording.
257 // If it hasn't then skip it. There is no other simple way to handle
258 // this as it depends on the timing of requests and responses. Throwing
259 // will make some recordings/unit tests fail randomly depending on how
260 // fast/slow the response arrived.
261 // If you are seeing this error then you need to make sure that all
262 // the requests made during a single recording session finish before
263 // ending the same recording session.
264 if (thisRecordingId !== currentRecordingId) {
265 debug('skipping recording of an out-of-order request', out)
266 return
267 }
268
269 outputs.push(out)
270
271 if (!dontPrint) {
272 if (useSeparator) {
273 if (typeof out !== 'string') {
274 out = JSON.stringify(out, null, 2)
275 }
276 logging(SEPARATOR + out + SEPARATOR)
277 } else {
278 logging(out)
279 }
280 }
281 })
282
283 let encoding
284 // We need to be aware of changes to the stream's encoding so that we
285 // don't accidentally mangle the data.
286 const { setEncoding } = res
287 res.setEncoding = function(newEncoding) {
288 encoding = newEncoding
289 return setEncoding.apply(this, arguments)
290 }
291
292 const dataChunks = []
293 // Replace res.push with our own implementation that stores chunks
294 const origResPush = res.push
295 res.push = function(data) {
296 if (data) {
297 if (encoding) {
298 data = Buffer.from(data, encoding)
299 }
300 dataChunks.push(data)
301 }
302
303 return origResPush.call(res, data)
304 }
305
306 if (callback) {
307 callback(res, options, callback)
308 } else {
309 res.resume()
310 }
311
312 debug('finished setting up intercepting')
313
314 // We override both the http and the https modules; when we are
315 // serializing the request, we need to know which was called.
316 // By stuffing the state, we can make sure that nock records
317 // the intended protocol.
318 if (proto === 'https') {
319 options.proto = 'https'
320 }
321 })
322
323 const recordChunk = (chunk, encoding) => {
324 debug(thisRecordingId, 'new', proto, 'body chunk')
325 if (!Buffer.isBuffer(chunk)) {
326 chunk = Buffer.from(chunk, encoding)
327 }
328 bodyChunks.push(chunk)
329 }
330
331 const oldWrite = req.write
332 req.write = function(chunk, encoding) {
333 if (typeof chunk !== 'undefined') {
334 recordChunk(chunk, encoding)
335 oldWrite.apply(req, arguments)
336 } else {
337 throw new Error('Data was undefined.')
338 }
339 }
340
341 // Starting in Node 8, `OutgoingMessage.end()` directly calls an internal
342 // `write_` function instead of proxying to the public
343 // `OutgoingMessage.write()` method, so we have to wrap `end` too.
344 const oldEnd = req.end
345 req.end = function(chunk, encoding, callback) {
346 debug('req.end')
347 if (typeof chunk === 'function') {
348 callback = chunk
349 chunk = null
350 } else if (typeof encoding === 'function') {
351 callback = encoding
352 encoding = null
353 }
354
355 if (chunk) {
356 recordChunk(chunk, encoding)
357 }
358 oldEnd.call(req, chunk, encoding, callback)
359 }
360
361 return req
362 })
363}
364
365// Restore *all* the overridden http/https modules' properties.
366function restore() {
367 debug(
368 currentRecordingId,
369 'restoring all the overridden http/https properties'
370 )
371
372 common.restoreOverriddenRequests()
373 restoreOverriddenClientRequest()
374 recordingInProgress = false
375}
376
377function clear() {
378 outputs = []
379}
380
381module.exports = {
382 record,
383 outputs: () => outputs,
384 restore,
385 clear,
386}