Skip to content
Permalink
9bfb9ba527
Switch branches/tags

Name already in use

A tag already exists with the provided branch name. Many Git commands accept both tag and branch names, so creating this branch may cause unexpected behavior. Are you sure you want to create this branch?
Go to file
 
 
Cannot retrieve contributors at this time
355 lines (292 sloc) 11.1 KB
'use strict'
const debug = require('debug')('nock.request_overrider')
const {
IncomingMessage,
ClientRequest,
request: originalHttpRequest,
} = require('http')
const { request: originalHttpsRequest } = require('https')
const propagate = require('propagate')
const common = require('./common')
const globalEmitter = require('./global_emitter')
const Socket = require('./socket')
const { playbackInterceptor } = require('./playback_interceptor')
function socketOnClose(req) {
debug('socket close')
if (!req.res && !req.socket._hadError) {
// If we don't have a response then we know that the socket
// ended prematurely and we need to emit an error on the request.
req.socket._hadError = true
const err = new Error('socket hang up')
err.code = 'ECONNRESET'
req.emit('error', err)
}
req.emit('close')
}
/**
* Given a group of interceptors, appropriately route an outgoing request.
* Identify which interceptor ought to respond, if any, then delegate to
* `playbackInterceptor()` to consume the request itself.
*/
class InterceptedRequestRouter {
constructor({ req, options, interceptors }) {
this.req = req
this.options = {
// We may be changing the options object and we don't want those changes
// affecting the user so we use a clone of the object.
...options,
// We use lower-case header field names throughout Nock.
headers: common.headersFieldNamesToLowerCase(
options.headers || {},
false
),
}
this.interceptors = interceptors
this.socket = new Socket(options)
// support setting `timeout` using request `options`
// https://nodejs.org/docs/latest-v12.x/api/http.html#http_http_request_url_options_callback
// any timeout in the request options override any timeout in the agent options.
// per https://github.com/nodejs/node/pull/21204
const timeout =
options.timeout ||
(options.agent && options.agent.options && options.agent.options.timeout)
if (timeout) {
this.socket.setTimeout(timeout)
}
this.response = new IncomingMessage(this.socket)
this.requestBodyBuffers = []
this.playbackStarted = false
// For parity with Node, it's important the socket event is emitted before we begin playback.
// This flag is set when playback is triggered if we haven't yet gotten the
// socket event to indicate that playback should start as soon as it comes in.
this.readyToStartPlaybackOnSocketEvent = false
this.attachToReq()
// Emit a fake socket event on the next tick to mimic what would happen on a real request.
// Some clients listen for a 'socket' event to be emitted before calling end(),
// which causes Nock to hang.
process.nextTick(() => this.connectSocket())
}
attachToReq() {
const { req, options } = this
for (const [name, val] of Object.entries(options.headers)) {
req.setHeader(name.toLowerCase(), val)
}
if (options.auth && !options.headers.authorization) {
req.setHeader(
// We use lower-case header field names throughout Nock.
'authorization',
`Basic ${Buffer.from(options.auth).toString('base64')}`
)
}
req.path = options.path
req.method = options.method
req.write = (...args) => this.handleWrite(...args)
req.end = (...args) => this.handleEnd(...args)
req.flushHeaders = (...args) => this.handleFlushHeaders(...args)
// https://github.com/nock/nock/issues/256
if (options.headers.expect === '100-continue') {
common.setImmediate(() => {
debug('continue')
req.emit('continue')
})
}
}
connectSocket() {
const { req, socket } = this
if (common.isRequestDestroyed(req)) {
return
}
// ClientRequest.connection is an alias for ClientRequest.socket
// https://nodejs.org/api/http.html#http_request_socket
// https://github.com/nodejs/node/blob/b0f75818f39ed4e6bd80eb7c4010c1daf5823ef7/lib/_http_client.js#L640-L641
// The same Socket is shared between the request and response to mimic native behavior.
req.socket = req.connection = socket
propagate(['error', 'timeout'], socket, req)
socket.on('close', () => socketOnClose(req))
socket.connecting = false
req.emit('socket', socket)
// https://nodejs.org/api/net.html#net_event_connect
socket.emit('connect')
// https://nodejs.org/api/tls.html#tls_event_secureconnect
if (socket.authorized) {
socket.emit('secureConnect')
}
if (this.readyToStartPlaybackOnSocketEvent) {
this.maybeStartPlayback()
}
}
// from docs: When write function is called with empty string or buffer, it does nothing and waits for more input.
// However, actually implementation checks the state of finished and aborted before checking if the first arg is empty.
handleWrite(...args) {
debug('request write')
let [buffer, encoding] = args
const { req } = this
if (req.finished) {
const err = new Error('write after end')
err.code = 'ERR_STREAM_WRITE_AFTER_END'
process.nextTick(() => req.emit('error', err))
// It seems odd to return `true` here, not sure why you'd want to have
// the stream potentially written to more, but it's what Node does.
// https://github.com/nodejs/node/blob/a9270dcbeba4316b1e179b77ecb6c46af5aa8c20/lib/_http_outgoing.js#L662-L665
return true
}
if (req.socket && req.socket.destroyed) {
return false
}
if (!buffer) {
return true
}
if (!Buffer.isBuffer(buffer)) {
buffer = Buffer.from(buffer, encoding)
}
this.requestBodyBuffers.push(buffer)
// writable.write encoding param is optional
// so if callback is present it's the last argument
const callback = args.length > 1 ? args[args.length - 1] : undefined
// can't use instanceof Function because some test runners
// run tests in vm.runInNewContext where Function is not same
// as that in the current context
// https://github.com/nock/nock/pull/1754#issuecomment-571531407
if (typeof callback === 'function') {
callback()
}
common.setImmediate(function () {
req.emit('drain')
})
return false
}
handleEnd(chunk, encoding, callback) {
debug('request end')
const { req } = this
// handle the different overloaded arg signatures
if (typeof chunk === 'function') {
callback = chunk
chunk = null
} else if (typeof encoding === 'function') {
callback = encoding
encoding = null
}
if (typeof callback === 'function') {
req.once('finish', callback)
}
if (chunk) {
req.write(chunk, encoding)
}
req.finished = true
this.maybeStartPlayback()
return req
}
handleFlushHeaders() {
debug('request flushHeaders')
this.maybeStartPlayback()
}
/**
* Set request headers of the given request. This is needed both during the
* routing phase, in case header filters were specified, and during the
* interceptor-playback phase, to correctly pass mocked request headers.
* TODO There are some problems with this; see https://github.com/nock/nock/issues/1718
*/
setHostHeaderUsingInterceptor(interceptor) {
const { req, options } = this
// If a filtered scope is being used we have to use scope's host in the
// header, otherwise 'host' header won't match.
// NOTE: We use lower-case header field names throughout Nock.
const HOST_HEADER = 'host'
if (interceptor.__nock_filteredScope && interceptor.__nock_scopeHost) {
options.headers[HOST_HEADER] = interceptor.__nock_scopeHost
req.setHeader(HOST_HEADER, interceptor.__nock_scopeHost)
} else {
// For all other cases, we always add host header equal to the requested
// host unless it was already defined.
if (options.host && !req.getHeader(HOST_HEADER)) {
let hostHeader = options.host
if (options.port === 80 || options.port === 443) {
hostHeader = hostHeader.split(':')[0]
}
req.setHeader(HOST_HEADER, hostHeader)
}
}
}
maybeStartPlayback() {
const { req, socket, playbackStarted } = this
// In order to get the events in the right order we need to delay playback
// if we get here before the `socket` event is emitted.
if (socket.connecting) {
this.readyToStartPlaybackOnSocketEvent = true
return
}
if (!common.isRequestDestroyed(req) && !playbackStarted) {
this.startPlayback()
}
}
startPlayback() {
debug('ending')
this.playbackStarted = true
const { req, response, socket, options, interceptors } = this
Object.assign(options, {
// Re-update `options` with the current value of `req.path` because badly
// behaving agents like superagent like to change `req.path` mid-flight.
path: req.path,
// Similarly, node-http-proxy will modify headers in flight, so we have
// to put the headers back into options.
// https://github.com/nock/nock/pull/1484
headers: req.getHeaders(),
// Fixes https://github.com/nock/nock/issues/976
protocol: `${options.proto}:`,
})
interceptors.forEach(interceptor => {
this.setHostHeaderUsingInterceptor(interceptor)
})
const requestBodyBuffer = Buffer.concat(this.requestBodyBuffers)
// When request body is a binary buffer we internally use in its hexadecimal
// representation.
const requestBodyIsUtf8Representable =
common.isUtf8Representable(requestBodyBuffer)
const requestBodyString = requestBodyBuffer.toString(
requestBodyIsUtf8Representable ? 'utf8' : 'hex'
)
const matchedInterceptor = interceptors.find(i =>
i.match(req, options, requestBodyString)
)
if (matchedInterceptor) {
matchedInterceptor.scope.logger(
'interceptor identified, starting mocking'
)
matchedInterceptor.markConsumed()
// wait to emit the finish event until we know for sure an Interceptor is going to playback.
// otherwise an unmocked request might emit finish twice.
req.emit('finish')
playbackInterceptor({
req,
socket,
options,
requestBodyString,
requestBodyIsUtf8Representable,
response,
interceptor: matchedInterceptor,
})
} else {
globalEmitter.emit('no match', req, options, requestBodyString)
// Try to find a hostname match that allows unmocked.
const allowUnmocked = interceptors.some(
i => i.matchHostName(options) && i.options.allowUnmocked
)
if (allowUnmocked && req instanceof ClientRequest) {
const newReq =
options.proto === 'https'
? originalHttpsRequest(options)
: originalHttpRequest(options)
propagate(newReq, req)
// We send the raw buffer as we received it, not as we interpreted it.
newReq.end(requestBodyBuffer)
} else {
const reqStr = common.stringifyRequest(options, requestBodyString)
const err = new Error(`Nock: No match for request ${reqStr}`)
err.code = 'ERR_NOCK_NO_MATCH'
err.statusCode = err.status = 404
req.destroy(err)
}
}
}
}
module.exports = { InterceptedRequestRouter }