UNPKG

9.46 kBJavaScriptView Raw
1'use strict'
2
3const debug = require('debug')('nock.request_overrider')
4const {
5 IncomingMessage,
6 ClientRequest,
7 request: originalHttpRequest,
8} = require('http')
9const { request: originalHttpsRequest } = require('https')
10const propagate = require('propagate')
11const common = require('./common')
12const globalEmitter = require('./global_emitter')
13const Socket = require('./socket')
14const { playbackInterceptor } = require('./playback_interceptor')
15
16/**
17 * Given a group of interceptors, appropriately route an outgoing request.
18 * Identify which interceptor ought to respond, if any, then delegate to
19 * `playbackInterceptor()` to consume the request itself.
20 */
21class InterceptedRequestRouter {
22 constructor({ req, options, interceptors }) {
23 this.req = req
24 this.options = {
25 // We may be changing the options object and we don't want those changes
26 // affecting the user so we use a clone of the object.
27 ...options,
28 // We use lower-case header field names throughout Nock.
29 headers: common.headersFieldNamesToLowerCase(options.headers || {}),
30 }
31 this.interceptors = interceptors
32
33 this.socket = new Socket(options)
34
35 // support setting `timeout` using request `options`
36 // https://nodejs.org/docs/latest-v12.x/api/http.html#http_http_request_url_options_callback
37 if (options.timeout) {
38 this.socket.setTimeout(options.timeout)
39 }
40
41 this.response = new IncomingMessage(this.socket)
42 this.playbackStarted = false
43 this.requestBodyBuffers = []
44
45 this.attachToReq()
46 }
47
48 attachToReq() {
49 const { req, response, socket, options } = this
50
51 response.req = req
52
53 for (const [name, val] of Object.entries(options.headers)) {
54 req.setHeader(name.toLowerCase(), val)
55 }
56
57 if (options.auth && !options.headers.authorization) {
58 req.setHeader(
59 // We use lower-case header field names throughout Nock.
60 'authorization',
61 `Basic ${Buffer.from(options.auth).toString('base64')}`
62 )
63 }
64
65 req.path = options.path
66 req.method = options.method
67
68 // ClientRequest.connection is an alias for ClientRequest.socket
69 // https://nodejs.org/api/http.html#http_request_socket
70 // https://github.com/nodejs/node/blob/b0f75818f39ed4e6bd80eb7c4010c1daf5823ef7/lib/_http_client.js#L640-L641
71 // The same Socket is shared between the request and response to mimic native behavior.
72 req.socket = req.connection = socket
73
74 propagate(['error', 'timeout'], req.socket, req)
75
76 req.write = (...args) => this.handleWrite(...args)
77 req.end = (...args) => this.handleEnd(...args)
78 req.flushHeaders = (...args) => this.handleFlushHeaders(...args)
79 req.abort = (...args) => this.handleAbort(...args)
80
81 // https://github.com/nock/nock/issues/256
82 if (options.headers.expect === '100-continue') {
83 common.setImmediate(() => {
84 debug('continue')
85 req.emit('continue')
86 })
87 }
88
89 // Emit a fake socket event on the next tick to mimic what would happen on a real request.
90 // Some clients listen for a 'socket' event to be emitted before calling end(),
91 // which causes nock to hang.
92 process.nextTick(() => {
93 req.emit('socket', socket)
94
95 // https://nodejs.org/api/net.html#net_event_connect
96 socket.emit('connect')
97
98 // https://nodejs.org/api/tls.html#tls_event_secureconnect
99 if (socket.authorized) {
100 socket.emit('secureConnect')
101 }
102 })
103 }
104
105 emitError(error) {
106 const { req } = this
107 process.nextTick(() => {
108 req.emit('error', error)
109 })
110 }
111
112 handleWrite(buffer, encoding, callback) {
113 debug('write', arguments)
114 const { req } = this
115
116 if (!req.aborted) {
117 if (buffer) {
118 if (!Buffer.isBuffer(buffer)) {
119 buffer = Buffer.from(buffer, encoding)
120 }
121 this.requestBodyBuffers.push(buffer)
122 }
123 // can't use instanceof Function because some test runners
124 // run tests in vm.runInNewContext where Function is not same
125 // as that in the current context
126 // https://github.com/nock/nock/pull/1754#issuecomment-571531407
127 if (typeof callback === 'function') {
128 callback()
129 }
130 } else {
131 this.emitError(new Error('Request aborted'))
132 }
133
134 common.setImmediate(function() {
135 req.emit('drain')
136 })
137
138 return false
139 }
140
141 handleEnd(chunk, encoding, callback) {
142 debug('req.end')
143 const { req } = this
144
145 if (typeof chunk === 'function') {
146 callback = chunk
147 chunk = null
148 } else if (typeof encoding === 'function') {
149 callback = encoding
150 encoding = null
151 }
152
153 if (!req.aborted && !this.playbackStarted) {
154 req.write(chunk, encoding, () => {
155 if (typeof callback === 'function') {
156 callback()
157 }
158 this.startPlayback()
159 req.emit('finish')
160 req.emit('end')
161 })
162 }
163 if (req.aborted) {
164 this.emitError(new Error('Request aborted'))
165 }
166 }
167
168 handleFlushHeaders() {
169 debug('req.flushHeaders')
170 const { req } = this
171
172 if (!req.aborted && !this.playbackStarted) {
173 this.startPlayback()
174 }
175 if (req.aborted) {
176 this.emitError(new Error('Request aborted'))
177 }
178 }
179
180 handleAbort() {
181 debug('req.abort')
182 const { req, response, socket } = this
183
184 if (req.aborted) {
185 return
186 }
187 req.aborted = Date.now()
188 if (!this.playbackStarted) {
189 this.startPlayback()
190 }
191 const err = new Error()
192 err.code = 'aborted'
193 response.emit('close', err)
194
195 socket.destroy()
196
197 req.emit('abort')
198
199 const connResetError = new Error('socket hang up')
200 connResetError.code = 'ECONNRESET'
201 this.emitError(connResetError)
202 }
203
204 /**
205 * Set request headers of the given request. This is needed both during the
206 * routing phase, in case header filters were specified, and during the
207 * interceptor-playback phase, to correctly pass mocked request headers.
208 * TODO There are some problems with this; see https://github.com/nock/nock/issues/1718
209 */
210 setHostHeaderUsingInterceptor(interceptor) {
211 const { req, options } = this
212
213 // If a filtered scope is being used we have to use scope's host in the
214 // header, otherwise 'host' header won't match.
215 // NOTE: We use lower-case header field names throughout Nock.
216 const HOST_HEADER = 'host'
217 if (interceptor.__nock_filteredScope && interceptor.__nock_scopeHost) {
218 options.headers[HOST_HEADER] = interceptor.__nock_scopeHost
219 req.setHeader(HOST_HEADER, interceptor.__nock_scopeHost)
220 } else {
221 // For all other cases, we always add host header equal to the requested
222 // host unless it was already defined.
223 if (options.host && !req.getHeader(HOST_HEADER)) {
224 let hostHeader = options.host
225
226 if (options.port === 80 || options.port === 443) {
227 hostHeader = hostHeader.split(':')[0]
228 }
229
230 req.setHeader(HOST_HEADER, hostHeader)
231 }
232 }
233 }
234
235 startPlayback() {
236 debug('ending')
237 this.playbackStarted = true
238
239 const { req, response, socket, options, interceptors } = this
240
241 Object.assign(options, {
242 // Re-update `options` with the current value of `req.path` because badly
243 // behaving agents like superagent like to change `req.path` mid-flight.
244 path: req.path,
245 // Similarly, node-http-proxy will modify headers in flight, so we have
246 // to put the headers back into options.
247 // https://github.com/nock/nock/pull/1484
248 headers: req.getHeaders(),
249 // Fixes https://github.com/nock/nock/issues/976
250 protocol: `${options.proto}:`,
251 })
252
253 interceptors.forEach(interceptor => {
254 this.setHostHeaderUsingInterceptor(interceptor)
255 })
256
257 const requestBodyBuffer = Buffer.concat(this.requestBodyBuffers)
258 // When request body is a binary buffer we internally use in its hexadecimal
259 // representation.
260 const requestBodyIsUtf8Representable = common.isUtf8Representable(
261 requestBodyBuffer
262 )
263 const requestBodyString = requestBodyBuffer.toString(
264 requestBodyIsUtf8Representable ? 'utf8' : 'hex'
265 )
266
267 const matchedInterceptor = interceptors.find(i =>
268 i.match(req, options, requestBodyString)
269 )
270
271 if (matchedInterceptor) {
272 debug('interceptor identified, starting mocking')
273
274 playbackInterceptor({
275 req,
276 socket,
277 options,
278 requestBodyString,
279 requestBodyIsUtf8Representable,
280 response,
281 interceptor: matchedInterceptor,
282 })
283 } else {
284 globalEmitter.emit('no match', req, options, requestBodyString)
285
286 // Try to find a hostname match that allows unmocked.
287 const allowUnmocked = interceptors.some(
288 i => i.matchHostName(options) && i.options.allowUnmocked
289 )
290
291 if (allowUnmocked && req instanceof ClientRequest) {
292 const newReq =
293 options.proto === 'https'
294 ? originalHttpsRequest(options)
295 : originalHttpRequest(options)
296
297 propagate(newReq, req)
298 // We send the raw buffer as we received it, not as we interpreted it.
299 newReq.end(requestBodyBuffer)
300 } else {
301 const err = new Error(
302 `Nock: No match for request ${common.stringifyRequest(
303 options,
304 requestBodyString
305 )}`
306 )
307 err.statusCode = err.status = 404
308 this.emitError(err)
309 }
310 }
311 }
312}
313
314module.exports = { InterceptedRequestRouter }