You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
320 lines
9.5 KiB
320 lines
9.5 KiB
'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')
|
|
|
|
/**
|
|
* 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 || {}),
|
|
}
|
|
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
|
|
if (options.timeout) {
|
|
this.socket.setTimeout(options.timeout)
|
|
}
|
|
|
|
this.response = new IncomingMessage(this.socket)
|
|
this.playbackStarted = false
|
|
this.requestBodyBuffers = []
|
|
|
|
this.attachToReq()
|
|
}
|
|
|
|
attachToReq() {
|
|
const { req, response, socket, options } = this
|
|
|
|
response.req = req
|
|
|
|
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
|
|
|
|
// 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'], req.socket, req)
|
|
|
|
req.write = (...args) => this.handleWrite(...args)
|
|
req.end = (...args) => this.handleEnd(...args)
|
|
req.flushHeaders = (...args) => this.handleFlushHeaders(...args)
|
|
req.abort = (...args) => this.handleAbort(...args)
|
|
|
|
// https://github.com/nock/nock/issues/256
|
|
if (options.headers.expect === '100-continue') {
|
|
common.setImmediate(() => {
|
|
debug('continue')
|
|
req.emit('continue')
|
|
})
|
|
}
|
|
|
|
// 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(() => {
|
|
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')
|
|
}
|
|
})
|
|
}
|
|
|
|
emitError(error) {
|
|
const { req } = this
|
|
process.nextTick(() => {
|
|
req.emit('error', error)
|
|
})
|
|
}
|
|
|
|
handleWrite(buffer, encoding, callback) {
|
|
debug('write', arguments)
|
|
const { req } = this
|
|
|
|
if (!req.aborted) {
|
|
if (buffer) {
|
|
if (!Buffer.isBuffer(buffer)) {
|
|
buffer = Buffer.from(buffer, encoding)
|
|
}
|
|
this.requestBodyBuffers.push(buffer)
|
|
}
|
|
// 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()
|
|
}
|
|
} else {
|
|
this.emitError(new Error('Request aborted'))
|
|
}
|
|
|
|
common.setImmediate(function() {
|
|
req.emit('drain')
|
|
})
|
|
|
|
return false
|
|
}
|
|
|
|
handleEnd(chunk, encoding, callback) {
|
|
debug('req.end')
|
|
const { req } = this
|
|
|
|
// handle the different overloaded param 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 (!req.aborted && !this.playbackStarted) {
|
|
req.write(chunk, encoding)
|
|
this.startPlayback()
|
|
}
|
|
if (req.aborted) {
|
|
this.emitError(new Error('Request aborted'))
|
|
}
|
|
|
|
return req
|
|
}
|
|
|
|
handleFlushHeaders() {
|
|
debug('req.flushHeaders')
|
|
const { req } = this
|
|
|
|
if (!req.aborted && !this.playbackStarted) {
|
|
this.startPlayback()
|
|
}
|
|
if (req.aborted) {
|
|
this.emitError(new Error('Request aborted'))
|
|
}
|
|
}
|
|
|
|
handleAbort() {
|
|
debug('req.abort')
|
|
const { req, response, socket } = this
|
|
|
|
if (req.aborted) {
|
|
return
|
|
}
|
|
req.aborted = Date.now()
|
|
if (!this.playbackStarted) {
|
|
this.startPlayback()
|
|
}
|
|
const err = new Error()
|
|
err.code = 'aborted'
|
|
response.emit('close', err)
|
|
|
|
socket.destroy()
|
|
|
|
req.emit('abort')
|
|
|
|
const connResetError = new Error('socket hang up')
|
|
connResetError.code = 'ECONNRESET'
|
|
this.emitError(connResetError)
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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) {
|
|
debug('interceptor identified, starting mocking')
|
|
|
|
// 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.finished = true
|
|
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 err = new Error(
|
|
`Nock: No match for request ${common.stringifyRequest(
|
|
options,
|
|
requestBodyString
|
|
)}`
|
|
)
|
|
err.statusCode = err.status = 404
|
|
this.emitError(err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = { InterceptedRequestRouter }
|
|
|