'use strict' const util = require('util') const zlib = require('zlib') const debug = require('debug')('nock.playback_interceptor') const common = require('./common') const DelayedBody = require('./delayed_body') function parseJSONRequestBody(req, requestBody) { if (!requestBody || !common.isJSONContent(req.headers)) { return requestBody } if (common.contentEncoding(req.headers, 'gzip')) { requestBody = String(zlib.gunzipSync(Buffer.from(requestBody, 'hex'))) } else if (common.contentEncoding(req.headers, 'deflate')) { requestBody = String(zlib.inflateSync(Buffer.from(requestBody, 'hex'))) } return JSON.parse(requestBody) } function parseFullReplyResult(response, fullReplyResult) { debug('full response from callback result: %j', fullReplyResult) if (!Array.isArray(fullReplyResult)) { throw Error('A single function provided to .reply MUST return an array') } if (fullReplyResult.length > 3) { throw Error( 'The array returned from the .reply callback contains too many values' ) } const [status, body = '', headers] = fullReplyResult if (!Number.isInteger(status)) { throw new Error(`Invalid ${typeof status} value for status code`) } response.statusCode = status response.rawHeaders.push(...common.headersInputToRawArray(headers)) debug('response.rawHeaders after reply: %j', response.rawHeaders) return body } /** * Determine which of the default headers should be added to the response. * * Don't include any defaults whose case-insensitive keys are already on the response. */ function selectDefaultHeaders(existingHeaders, defaultHeaders) { if (!defaultHeaders.length) { return [] // return early if we don't need to bother } const definedHeaders = new Set() const result = [] common.forEachHeader(existingHeaders, (_, fieldName) => { definedHeaders.add(fieldName.toLowerCase()) }) common.forEachHeader(defaultHeaders, (value, fieldName) => { if (!definedHeaders.has(fieldName.toLowerCase())) { result.push(fieldName, value) } }) return result } /** * Play back an interceptor using the given request and mock response. */ function playbackInterceptor({ req, socket, options, requestBodyString, requestBodyIsUtf8Representable, response, interceptor, }) { function emitError(error) { process.nextTick(() => { req.emit('error', error) }) } function start() { interceptor.req = req req.headers = req.getHeaders() interceptor.scope.emit('request', req, interceptor, requestBodyString) if (typeof interceptor.errorMessage !== 'undefined') { interceptor.markConsumed() let error if (typeof interceptor.errorMessage === 'object') { error = interceptor.errorMessage } else { error = new Error(interceptor.errorMessage) } common.setTimeout(() => emitError(error), interceptor.getTotalDelay()) return } // This will be null if we have a fullReplyFunction, // in that case status code will be set in `parseFullReplyResult` response.statusCode = interceptor.statusCode // Clone headers/rawHeaders to not override them when evaluating later response.rawHeaders = [...interceptor.rawHeaders] debug('response.rawHeaders:', response.rawHeaders) if (interceptor.replyFunction) { const parsedRequestBody = parseJSONRequestBody(req, requestBodyString) let fn = interceptor.replyFunction if (fn.length === 3) { // Handle the case of an async reply function, the third parameter being the callback. fn = util.promisify(fn) } // At this point `fn` is either a synchronous function or a promise-returning function; // wrapping in `Promise.resolve` makes it into a promise either way. Promise.resolve(fn.call(interceptor, options.path, parsedRequestBody)) .then(responseBody => continueWithResponseBody({ responseBody })) .catch(err => emitError(err)) return } if (interceptor.fullReplyFunction) { const parsedRequestBody = parseJSONRequestBody(req, requestBodyString) let fn = interceptor.fullReplyFunction if (fn.length === 3) { fn = util.promisify(fn) } Promise.resolve(fn.call(interceptor, options.path, parsedRequestBody)) .then(fullReplyResult => continueWithFullResponse({ fullReplyResult })) .catch(err => emitError(err)) return } if ( common.isContentEncoded(interceptor.headers) && !common.isStream(interceptor.body) ) { // If the content is encoded we know that the response body *must* be an array // of response buffers which should be mocked one by one. // (otherwise decompressions after the first one fails as unzip expects to receive // buffer by buffer and not one single merged buffer) if (interceptor.delayInMs) { emitError( new Error( 'Response delay of the body is currently not supported with content-encoded responses.' ) ) return } const bufferData = Array.isArray(interceptor.body) ? interceptor.body : [interceptor.body] const responseBuffers = bufferData.map(data => Buffer.from(data, 'hex')) continueWithResponseBody({ responseBuffers }) return } // If we get to this point, the body is either a string or an object that // will eventually be JSON stringified. let responseBody = interceptor.body // If the request was not UTF8-representable then we assume that the // response won't be either. In that case we send the response as a Buffer // object as that's what the client will expect. if (!requestBodyIsUtf8Representable && typeof responseBody === 'string') { // Try to create the buffer from the interceptor's body response as hex. responseBody = Buffer.from(responseBody, 'hex') // Creating buffers does not necessarily throw errors; check for difference in size. if ( !responseBody || (interceptor.body.length > 0 && responseBody.length === 0) ) { // We fallback on constructing buffer from utf8 representation of the body. responseBody = Buffer.from(interceptor.body, 'utf8') } } return continueWithResponseBody({ responseBody }) } function continueWithFullResponse({ fullReplyResult }) { let responseBody try { responseBody = parseFullReplyResult(response, fullReplyResult) } catch (innerErr) { emitError(innerErr) return } continueWithResponseBody({ responseBody }) } function continueWithResponseBody({ responseBuffers, responseBody }) { // Transform the response body if it exists (it may not exist // if we have `responseBuffers` instead) if (responseBody !== undefined) { debug('transform the response body') if (interceptor.delayInMs) { debug( 'delaying the response for', interceptor.delayInMs, 'milliseconds' ) // Because setTimeout is called immediately in DelayedBody(), so we // need count in the delayConnectionInMs. responseBody = new DelayedBody( interceptor.getTotalDelay(), responseBody ) } if (common.isStream(responseBody)) { debug('response body is a stream') responseBody.pause() responseBody.on('data', function(d) { response.push(d) }) responseBody.on('end', function() { response.push(null) // https://nodejs.org/dist/latest-v10.x/docs/api/http.html#http_message_complete response.complete = true }) responseBody.on('error', function(err) { response.emit('error', err) }) } else if (!Buffer.isBuffer(responseBody)) { if (typeof responseBody === 'string') { responseBody = Buffer.from(responseBody) } else { responseBody = JSON.stringify(responseBody) response.rawHeaders.push('Content-Type', 'application/json') } } // Why are strings converted to a Buffer, but JSON data is left as a string? // Related to https://github.com/nock/nock/issues/1542 ? } interceptor.markConsumed() if (req.aborted) { return } response.rawHeaders.push( ...selectDefaultHeaders( response.rawHeaders, interceptor.scope._defaultReplyHeaders ) ) // Evaluate functional headers. common.forEachHeader(response.rawHeaders, (value, fieldName, i) => { if (typeof value === 'function') { response.rawHeaders[i + 1] = value(req, response, responseBody) } }) response.headers = common.headersArrayToObject(response.rawHeaders) process.nextTick(() => respondUsingInterceptor({ responseBody, responseBuffers, }) ) } function respondUsingInterceptor({ responseBody, responseBuffers }) { if (req.aborted) { return } function respond() { if (req.aborted) { return } debug('emitting response') req.emit('response', response) if (common.isStream(responseBody)) { debug('resuming response stream') responseBody.resume() } else { responseBuffers = responseBuffers || [] if (typeof responseBody !== 'undefined') { debug('adding body to buffer list') responseBuffers.push(responseBody) } // Stream the response chunks one at a time. common.setImmediate(function emitChunk() { const chunk = responseBuffers.shift() if (chunk) { debug('emitting response chunk') response.push(chunk) common.setImmediate(emitChunk) } else { debug('ending response stream') response.push(null) // https://nodejs.org/dist/latest-v10.x/docs/api/http.html#http_message_complete response.complete = true interceptor.scope.emit('replied', req, interceptor) } }) } } if (interceptor.socketDelayInMs && interceptor.socketDelayInMs > 0) { socket.applyDelay(interceptor.socketDelayInMs) } if ( interceptor.delayConnectionInMs && interceptor.delayConnectionInMs > 0 ) { socket.applyDelay(interceptor.delayConnectionInMs) common.setTimeout(respond, interceptor.delayConnectionInMs) } else { respond() } } start() } module.exports = { playbackInterceptor }