UNPKG

10.2 kBJavaScriptView Raw
1'use strict'
2
3const stream = require('stream')
4const util = require('util')
5const zlib = require('zlib')
6const debug = require('debug')('nock.playback_interceptor')
7const common = require('./common')
8
9function parseJSONRequestBody(req, requestBody) {
10 if (!requestBody || !common.isJSONContent(req.headers)) {
11 return requestBody
12 }
13
14 if (common.contentEncoding(req.headers, 'gzip')) {
15 requestBody = String(zlib.gunzipSync(Buffer.from(requestBody, 'hex')))
16 } else if (common.contentEncoding(req.headers, 'deflate')) {
17 requestBody = String(zlib.inflateSync(Buffer.from(requestBody, 'hex')))
18 }
19
20 return JSON.parse(requestBody)
21}
22
23function parseFullReplyResult(response, fullReplyResult) {
24 debug('full response from callback result: %j', fullReplyResult)
25
26 if (!Array.isArray(fullReplyResult)) {
27 throw Error('A single function provided to .reply MUST return an array')
28 }
29
30 if (fullReplyResult.length > 3) {
31 throw Error(
32 'The array returned from the .reply callback contains too many values'
33 )
34 }
35
36 const [status, body = '', headers] = fullReplyResult
37
38 if (!Number.isInteger(status)) {
39 throw new Error(`Invalid ${typeof status} value for status code`)
40 }
41
42 response.statusCode = status
43 response.rawHeaders.push(...common.headersInputToRawArray(headers))
44 debug('response.rawHeaders after reply: %j', response.rawHeaders)
45
46 return body
47}
48
49/**
50 * Determine which of the default headers should be added to the response.
51 *
52 * Don't include any defaults whose case-insensitive keys are already on the response.
53 */
54function selectDefaultHeaders(existingHeaders, defaultHeaders) {
55 if (!defaultHeaders.length) {
56 return [] // return early if we don't need to bother
57 }
58
59 const definedHeaders = new Set()
60 const result = []
61
62 common.forEachHeader(existingHeaders, (_, fieldName) => {
63 definedHeaders.add(fieldName.toLowerCase())
64 })
65 common.forEachHeader(defaultHeaders, (value, fieldName) => {
66 if (!definedHeaders.has(fieldName.toLowerCase())) {
67 result.push(fieldName, value)
68 }
69 })
70
71 return result
72}
73
74// Presents a list of Buffers as a Readable
75class ReadableBuffers extends stream.Readable {
76 constructor(buffers, opts = {}) {
77 super(opts)
78
79 this.buffers = buffers
80 }
81
82 _read(size) {
83 while (this.buffers.length) {
84 if (!this.push(this.buffers.shift())) {
85 return
86 }
87 }
88 this.push(null)
89 }
90}
91
92function convertBodyToStream(body) {
93 if (common.isStream(body)) {
94 return body
95 }
96
97 if (body === undefined) {
98 return new ReadableBuffers([])
99 }
100
101 if (Buffer.isBuffer(body)) {
102 return new ReadableBuffers([body])
103 }
104
105 if (typeof body !== 'string') {
106 body = JSON.stringify(body)
107 }
108
109 return new ReadableBuffers([Buffer.from(body)])
110}
111
112/**
113 * Play back an interceptor using the given request and mock response.
114 */
115function playbackInterceptor({
116 req,
117 socket,
118 options,
119 requestBodyString,
120 requestBodyIsUtf8Representable,
121 response,
122 interceptor,
123}) {
124 const { logger } = interceptor.scope
125
126 function start() {
127 req.headers = req.getHeaders()
128
129 interceptor.scope.emit('request', req, interceptor, requestBodyString)
130
131 if (typeof interceptor.errorMessage !== 'undefined') {
132 let error
133 if (typeof interceptor.errorMessage === 'object') {
134 error = interceptor.errorMessage
135 } else {
136 error = new Error(interceptor.errorMessage)
137 }
138
139 const delay = interceptor.delayBodyInMs + interceptor.delayConnectionInMs
140 common.setTimeout(() => req.destroy(error), delay)
141 return
142 }
143
144 // This will be null if we have a fullReplyFunction,
145 // in that case status code will be set in `parseFullReplyResult`
146 response.statusCode = interceptor.statusCode
147
148 // Clone headers/rawHeaders to not override them when evaluating later
149 response.rawHeaders = [...interceptor.rawHeaders]
150 logger('response.rawHeaders:', response.rawHeaders)
151
152 // TODO: MAJOR: Don't tack the request onto the interceptor.
153 // The only reason we do this is so that it's available inside reply functions.
154 // It would be better to pass the request as an argument to the functions instead.
155 // Not adding the req as a third arg now because it should first be decided if (path, body, req)
156 // is the signature we want to go with going forward.
157 interceptor.req = req
158
159 if (interceptor.replyFunction) {
160 const parsedRequestBody = parseJSONRequestBody(req, requestBodyString)
161
162 let fn = interceptor.replyFunction
163 if (fn.length === 3) {
164 // Handle the case of an async reply function, the third parameter being the callback.
165 fn = util.promisify(fn)
166 }
167
168 // At this point `fn` is either a synchronous function or a promise-returning function;
169 // wrapping in `Promise.resolve` makes it into a promise either way.
170 Promise.resolve(fn.call(interceptor, options.path, parsedRequestBody))
171 .then(continueWithResponseBody)
172 .catch(err => req.destroy(err))
173 return
174 }
175
176 if (interceptor.fullReplyFunction) {
177 const parsedRequestBody = parseJSONRequestBody(req, requestBodyString)
178
179 let fn = interceptor.fullReplyFunction
180 if (fn.length === 3) {
181 fn = util.promisify(fn)
182 }
183
184 Promise.resolve(fn.call(interceptor, options.path, parsedRequestBody))
185 .then(continueWithFullResponse)
186 .catch(err => req.destroy(err))
187 return
188 }
189
190 if (
191 common.isContentEncoded(interceptor.headers) &&
192 !common.isStream(interceptor.body)
193 ) {
194 // If the content is encoded we know that the response body *must* be an array
195 // of response buffers which should be mocked one by one.
196 // (otherwise decompressions after the first one fails as unzip expects to receive
197 // buffer by buffer and not one single merged buffer)
198 const bufferData = Array.isArray(interceptor.body)
199 ? interceptor.body
200 : [interceptor.body]
201 const responseBuffers = bufferData.map(data => Buffer.from(data, 'hex'))
202 const responseBody = new ReadableBuffers(responseBuffers)
203 continueWithResponseBody(responseBody)
204 return
205 }
206
207 // If we get to this point, the body is either a string or an object that
208 // will eventually be JSON stringified.
209 let responseBody = interceptor.body
210
211 // If the request was not UTF8-representable then we assume that the
212 // response won't be either. In that case we send the response as a Buffer
213 // object as that's what the client will expect.
214 if (!requestBodyIsUtf8Representable && typeof responseBody === 'string') {
215 // Try to create the buffer from the interceptor's body response as hex.
216 responseBody = Buffer.from(responseBody, 'hex')
217
218 // Creating buffers does not necessarily throw errors; check for difference in size.
219 if (
220 !responseBody ||
221 (interceptor.body.length > 0 && responseBody.length === 0)
222 ) {
223 // We fallback on constructing buffer from utf8 representation of the body.
224 responseBody = Buffer.from(interceptor.body, 'utf8')
225 }
226 }
227
228 return continueWithResponseBody(responseBody)
229 }
230
231 function continueWithFullResponse(fullReplyResult) {
232 let responseBody
233 try {
234 responseBody = parseFullReplyResult(response, fullReplyResult)
235 } catch (err) {
236 req.destroy(err)
237 return
238 }
239
240 continueWithResponseBody(responseBody)
241 }
242
243 function prepareResponseHeaders(body) {
244 const defaultHeaders = [...interceptor.scope._defaultReplyHeaders]
245
246 // Include a JSON content type when JSON.stringify is called on the body.
247 // This is a convenience added by Nock that has no analog in Node. It's added to the
248 // defaults, so it will be ignored if the caller explicitly provided the header already.
249 const isJSON =
250 body !== undefined &&
251 typeof body !== 'string' &&
252 !Buffer.isBuffer(body) &&
253 !common.isStream(body)
254
255 if (isJSON) {
256 defaultHeaders.push('Content-Type', 'application/json')
257 }
258
259 response.rawHeaders.push(
260 ...selectDefaultHeaders(response.rawHeaders, defaultHeaders)
261 )
262
263 // Evaluate functional headers.
264 common.forEachHeader(response.rawHeaders, (value, fieldName, i) => {
265 if (typeof value === 'function') {
266 response.rawHeaders[i + 1] = value(req, response, body)
267 }
268 })
269
270 response.headers = common.headersArrayToObject(response.rawHeaders)
271 }
272
273 function continueWithResponseBody(rawBody) {
274 prepareResponseHeaders(rawBody)
275 const bodyAsStream = convertBodyToStream(rawBody)
276 bodyAsStream.pause()
277
278 // IncomingMessage extends Readable so we can't simply pipe.
279 bodyAsStream.on('data', function (chunk) {
280 response.push(chunk)
281 })
282 bodyAsStream.on('end', function () {
283 // https://nodejs.org/dist/latest-v10.x/docs/api/http.html#http_message_complete
284 response.complete = true
285 response.push(null)
286
287 interceptor.scope.emit('replied', req, interceptor)
288 })
289 bodyAsStream.on('error', function (err) {
290 response.emit('error', err)
291 })
292
293 const { delayBodyInMs, delayConnectionInMs } = interceptor
294
295 function respond() {
296 if (req.aborted) {
297 return
298 }
299
300 // Even though we've had the response object for awhile at this point,
301 // we only attach it to the request immediately before the `response`
302 // event because, as in Node, it alters the error handling around aborts.
303 req.res = response
304 response.req = req
305
306 logger('emitting response')
307 req.emit('response', response)
308
309 common.setTimeout(() => bodyAsStream.resume(), delayBodyInMs)
310 }
311
312 socket.applyDelay(delayConnectionInMs)
313 common.setTimeout(respond, delayConnectionInMs)
314 }
315
316 // Calling `start` immediately could take the request all the way to the connection delay
317 // during a single microtask execution. This setImmediate stalls the playback to ensure the
318 // correct events are emitted first ('socket', 'finish') and any aborts in the in the queue or
319 // called during a 'finish' listener can be called.
320 common.setImmediate(() => {
321 if (!req.aborted) {
322 start()
323 }
324 })
325}
326
327module.exports = { playbackInterceptor }