nock
Version:
HTTP server mocking and expectations library for Node.js
328 lines (266 loc) • 10.3 kB
JavaScript
const stream = require('stream')
const util = require('util')
const zlib = require('zlib')
const debug = require('debug')('nock.playback_interceptor')
const common = require('./common')
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
}
// Presents a list of Buffers as a Readable
class ReadableBuffers extends stream.Readable {
constructor(buffers, opts = {}) {
super(opts)
this.buffers = buffers
}
_read(_size) {
while (this.buffers.length) {
if (!this.push(this.buffers.shift())) {
return
}
}
this.push(null)
}
}
function convertBodyToStream(body) {
if (common.isStream(body)) {
return body
}
if (body === undefined) {
return new ReadableBuffers([])
}
if (Buffer.isBuffer(body)) {
return new ReadableBuffers([body])
}
if (typeof body !== 'string') {
body = JSON.stringify(body)
}
return new ReadableBuffers([Buffer.from(body)])
}
/**
* Play back an interceptor using the given request and mock response.
*/
function playbackInterceptor({
req,
socket,
options,
requestBodyString,
requestBodyIsUtf8Representable,
response,
interceptor,
}) {
const { logger } = interceptor.scope
function start() {
req.headers = req.getHeaders()
interceptor.scope.emit('request', req, interceptor, requestBodyString)
if (typeof interceptor.errorMessage !== 'undefined') {
let error
if (typeof interceptor.errorMessage === 'object') {
error = interceptor.errorMessage
} else {
error = new Error(interceptor.errorMessage)
}
const delay = interceptor.delayBodyInMs + interceptor.delayConnectionInMs
common.setTimeout(() => req.destroy(error), delay)
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]
logger('response.rawHeaders:', response.rawHeaders)
// TODO: MAJOR: Don't tack the request onto the interceptor.
// The only reason we do this is so that it's available inside reply functions.
// It would be better to pass the request as an argument to the functions instead.
// Not adding the req as a third arg now because it should first be decided if (path, body, req)
// is the signature we want to go with going forward.
interceptor.req = req
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(continueWithResponseBody)
.catch(err => req.destroy(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(continueWithFullResponse)
.catch(err => req.destroy(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)
const bufferData = Array.isArray(interceptor.body)
? interceptor.body
: [interceptor.body]
const responseBuffers = bufferData.map(data => Buffer.from(data, 'hex'))
const responseBody = new ReadableBuffers(responseBuffers)
continueWithResponseBody(responseBody)
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 (err) {
req.destroy(err)
return
}
continueWithResponseBody(responseBody)
}
function prepareResponseHeaders(body) {
const defaultHeaders = [...interceptor.scope._defaultReplyHeaders]
// Include a JSON content type when JSON.stringify is called on the body.
// This is a convenience added by Nock that has no analog in Node. It's added to the
// defaults, so it will be ignored if the caller explicitly provided the header already.
const isJSON =
body !== undefined &&
typeof body !== 'string' &&
!Buffer.isBuffer(body) &&
!common.isStream(body)
if (isJSON) {
defaultHeaders.push('Content-Type', 'application/json')
}
response.rawHeaders.push(
...selectDefaultHeaders(response.rawHeaders, defaultHeaders),
)
// Evaluate functional headers.
common.forEachHeader(response.rawHeaders, (value, fieldName, i) => {
if (typeof value === 'function') {
response.rawHeaders[i + 1] = value(req, response, body)
}
})
response.headers = common.headersArrayToObject(response.rawHeaders)
}
function continueWithResponseBody(rawBody) {
prepareResponseHeaders(rawBody)
const bodyAsStream = convertBodyToStream(rawBody)
bodyAsStream.pause()
// IncomingMessage extends Readable so we can't simply pipe.
bodyAsStream.on('data', function (chunk) {
response.push(chunk)
})
bodyAsStream.on('end', function () {
// https://nodejs.org/dist/latest-v10.x/docs/api/http.html#http_message_complete
response.complete = true
response.push(null)
interceptor.scope.emit('replied', req, interceptor)
})
bodyAsStream.on('error', function (err) {
response.emit('error', err)
})
const { delayBodyInMs, delayConnectionInMs } = interceptor
function respond() {
if (common.isRequestDestroyed(req)) {
return
}
// Even though we've had the response object for awhile at this point,
// we only attach it to the request immediately before the `response`
// event because, as in Node, it alters the error handling around aborts.
req.res = response
response.req = req
logger('emitting response')
req.emit('response', response)
common.setTimeout(() => bodyAsStream.resume(), delayBodyInMs)
}
socket.applyDelay(delayConnectionInMs)
common.setTimeout(respond, delayConnectionInMs)
}
// Calling `start` immediately could take the request all the way to the connection delay
// during a single microtask execution. This setImmediate stalls the playback to ensure the
// correct events are emitted first ('socket', 'finish') and any aborts in the queue or
// called during a 'finish' listener can be called.
common.setImmediate(() => {
if (!common.isRequestDestroyed(req)) {
start()
}
})
}
module.exports = { playbackInterceptor }