UNPKG

10.7 kBJavaScriptView Raw
1'use strict'
2
3/**
4 * @module nock/scope
5 */
6const { addInterceptor, isOn } = require('./intercept')
7const common = require('./common')
8const assert = require('assert')
9const url = require('url')
10const debug = require('debug')('nock.scope')
11const { EventEmitter } = require('events')
12const util = require('util')
13const Interceptor = require('./interceptor')
14
15let fs
16
17try {
18 fs = require('fs')
19} catch (err) {
20 // do nothing, we're in the browser
21}
22
23/**
24 * @param {string|RegExp|url.url} basePath
25 * @param {Object} options
26 * @param {boolean} options.allowUnmocked
27 * @param {string[]} options.badheaders
28 * @param {function} options.conditionally
29 * @param {boolean} options.encodedQueryParams
30 * @param {function} options.filteringScope
31 * @param {Object} options.reqheaders
32 * @constructor
33 */
34class Scope extends EventEmitter {
35 constructor(basePath, options) {
36 super()
37
38 this.keyedInterceptors = {}
39 this.interceptors = []
40 this.transformPathFunction = null
41 this.transformRequestBodyFunction = null
42 this.matchHeaders = []
43 this.logger = debug
44 this.scopeOptions = options || {}
45 this.urlParts = {}
46 this._persist = false
47 this.contentLen = false
48 this.date = null
49 this.basePath = basePath
50 this.basePathname = ''
51 this.port = null
52 this._defaultReplyHeaders = []
53
54 if (!(basePath instanceof RegExp)) {
55 this.urlParts = url.parse(basePath)
56 this.port =
57 this.urlParts.port || (this.urlParts.protocol === 'http:' ? 80 : 443)
58 this.basePathname = this.urlParts.pathname.replace(/\/$/, '')
59 this.basePath = `${this.urlParts.protocol}//${this.urlParts.hostname}:${this.port}`
60 }
61 }
62
63 add(key, interceptor) {
64 if (!(key in this.keyedInterceptors)) {
65 this.keyedInterceptors[key] = []
66 }
67 this.keyedInterceptors[key].push(interceptor)
68 addInterceptor(
69 this.basePath,
70 interceptor,
71 this,
72 this.scopeOptions,
73 this.urlParts.hostname
74 )
75 }
76
77 remove(key, interceptor) {
78 if (this._persist) {
79 return
80 }
81 const arr = this.keyedInterceptors[key]
82 if (arr) {
83 arr.splice(arr.indexOf(interceptor), 1)
84 if (arr.length === 0) {
85 delete this.keyedInterceptors[key]
86 }
87 }
88 }
89
90 intercept(uri, method, requestBody, interceptorOptions) {
91 const ic = new Interceptor(
92 this,
93 uri,
94 method,
95 requestBody,
96 interceptorOptions
97 )
98
99 this.interceptors.push(ic)
100 return ic
101 }
102
103 get(uri, requestBody, options) {
104 return this.intercept(uri, 'GET', requestBody, options)
105 }
106
107 post(uri, requestBody, options) {
108 return this.intercept(uri, 'POST', requestBody, options)
109 }
110
111 put(uri, requestBody, options) {
112 return this.intercept(uri, 'PUT', requestBody, options)
113 }
114
115 head(uri, requestBody, options) {
116 return this.intercept(uri, 'HEAD', requestBody, options)
117 }
118
119 patch(uri, requestBody, options) {
120 return this.intercept(uri, 'PATCH', requestBody, options)
121 }
122
123 merge(uri, requestBody, options) {
124 return this.intercept(uri, 'MERGE', requestBody, options)
125 }
126
127 delete(uri, requestBody, options) {
128 return this.intercept(uri, 'DELETE', requestBody, options)
129 }
130
131 options(uri, requestBody, options) {
132 return this.intercept(uri, 'OPTIONS', requestBody, options)
133 }
134
135 // Returns the list of keys for non-optional Interceptors that haven't been completed yet.
136 // TODO: This assumes that completed mocks are removed from the keyedInterceptors list
137 // (when persistence is off). We should change that (and this) in future.
138 pendingMocks() {
139 return this.activeMocks().filter(key =>
140 this.keyedInterceptors[key].some(({ interceptionCounter, optional }) => {
141 const persistedAndUsed = this._persist && interceptionCounter > 0
142 return !persistedAndUsed && !optional
143 })
144 )
145 }
146
147 // Returns all keyedInterceptors that are active.
148 // This includes incomplete interceptors, persisted but complete interceptors, and
149 // optional interceptors, but not non-persisted and completed interceptors.
150 activeMocks() {
151 return Object.keys(this.keyedInterceptors)
152 }
153
154 isDone() {
155 if (!isOn()) {
156 return true
157 }
158
159 return this.pendingMocks().length === 0
160 }
161
162 done() {
163 assert.ok(
164 this.isDone(),
165 `Mocks not yet satisfied:\n${this.pendingMocks().join('\n')}`
166 )
167 }
168
169 buildFilter() {
170 const filteringArguments = arguments
171
172 if (arguments[0] instanceof RegExp) {
173 return function(candidate) {
174 /* istanbul ignore if */
175 if (typeof candidate !== 'string') {
176 // Given the way nock is written, it seems like `candidate` will always
177 // be a string, regardless of what options might be passed to it.
178 // However the code used to contain a truthiness test of `candidate`.
179 // The check is being preserved for now.
180 throw Error(
181 `Nock internal assertion failed: typeof candidate is ${typeof candidate}. If you encounter this error, please report it as a bug.`
182 )
183 }
184 return candidate.replace(filteringArguments[0], filteringArguments[1])
185 }
186 } else if (typeof arguments[0] === 'function') {
187 return arguments[0]
188 }
189 }
190
191 filteringPath() {
192 this.transformPathFunction = this.buildFilter.apply(this, arguments)
193 if (!this.transformPathFunction) {
194 throw new Error(
195 'Invalid arguments: filtering path should be a function or a regular expression'
196 )
197 }
198 return this
199 }
200
201 filteringRequestBody() {
202 this.transformRequestBodyFunction = this.buildFilter.apply(this, arguments)
203 if (!this.transformRequestBodyFunction) {
204 throw new Error(
205 'Invalid arguments: filtering request body should be a function or a regular expression'
206 )
207 }
208 return this
209 }
210
211 matchHeader(name, value) {
212 // We use lower-case header field names throughout Nock.
213 this.matchHeaders.push({ name: name.toLowerCase(), value })
214 return this
215 }
216
217 defaultReplyHeaders(headers) {
218 this._defaultReplyHeaders = common.headersInputToRawArray(headers)
219 return this
220 }
221
222 log(newLogger) {
223 this.logger = newLogger
224 return this
225 }
226
227 persist(flag = true) {
228 if (typeof flag !== 'boolean') {
229 throw new Error('Invalid arguments: argument should be a boolean')
230 }
231 this._persist = flag
232 return this
233 }
234
235 /**
236 * @private
237 * @returns {boolean}
238 */
239 shouldPersist() {
240 return this._persist
241 }
242
243 replyContentLength() {
244 this.contentLen = true
245 return this
246 }
247
248 replyDate(d) {
249 this.date = d || new Date()
250 return this
251 }
252}
253
254function loadDefs(path) {
255 if (!fs) {
256 throw new Error('No fs')
257 }
258
259 const contents = fs.readFileSync(path)
260 return JSON.parse(contents)
261}
262
263function load(path) {
264 return define(loadDefs(path))
265}
266
267function getStatusFromDefinition(nockDef) {
268 // Backward compatibility for when `status` was encoded as string in `reply`.
269 if (nockDef.reply !== undefined) {
270 const parsedReply = parseInt(nockDef.reply, 10)
271 if (isNaN(parsedReply)) {
272 throw Error('`reply`, when present, must be a numeric string')
273 }
274
275 return parsedReply
276 }
277
278 const DEFAULT_STATUS_OK = 200
279 return nockDef.status || DEFAULT_STATUS_OK
280}
281
282function getScopeFromDefinition(nockDef) {
283 // Backward compatibility for when `port` was part of definition.
284 if (nockDef.port !== undefined) {
285 // Include `port` into scope if it doesn't exist.
286 const options = url.parse(nockDef.scope)
287 if (options.port === null) {
288 return `${nockDef.scope}:${nockDef.port}`
289 } else {
290 if (parseInt(options.port) !== parseInt(nockDef.port)) {
291 throw new Error(
292 'Mismatched port numbers in scope and port properties of nock definition.'
293 )
294 }
295 }
296 }
297
298 return nockDef.scope
299}
300
301function tryJsonParse(string) {
302 try {
303 return JSON.parse(string)
304 } catch (err) {
305 return string
306 }
307}
308
309// Use a noop deprecate util instead calling emitWarning directly so we get --no-deprecation and single warning behavior for free.
310const emitAsteriskDeprecation = util.deprecate(
311 () => {},
312 'Skipping body matching using "*" is deprecated. Set the definition body to undefined instead.',
313 'NOCK1579'
314)
315
316function define(nockDefs) {
317 const scopes = []
318
319 nockDefs.forEach(function(nockDef) {
320 const nscope = getScopeFromDefinition(nockDef)
321 const npath = nockDef.path
322 if (!nockDef.method) {
323 throw Error('Method is required')
324 }
325 const method = nockDef.method.toLowerCase()
326 const status = getStatusFromDefinition(nockDef)
327 const rawHeaders = nockDef.rawHeaders || []
328 const reqheaders = nockDef.reqheaders || {}
329 const badheaders = nockDef.badheaders || []
330 const options = { ...nockDef.options }
331
332 // We use request headers for both filtering (see below) and mocking.
333 // Here we are setting up mocked request headers but we don't want to
334 // be changing the user's options object so we clone it first.
335 options.reqheaders = reqheaders
336 options.badheaders = badheaders
337
338 let { body } = nockDef
339
340 if (body === '*') {
341 // In previous versions, it was impossible to NOT filter on request bodies. This special value
342 // is sniffed out for users manipulating the definitions and not wanting to match on the
343 // request body. For newer versions, users should remove the `body` key or set to `undefined`
344 // to achieve the same affect. Maintaining legacy behavior for now.
345 emitAsteriskDeprecation()
346 body = undefined
347 }
348
349 // Response is not always JSON as it could be a string or binary data or
350 // even an array of binary buffers (e.g. when content is encoded).
351 let response
352 if (!nockDef.response) {
353 response = ''
354 // TODO: Rename `responseIsBinary` to `reponseIsUtf8Representable`.
355 } else if (nockDef.responseIsBinary) {
356 response = Buffer.from(nockDef.response, 'hex')
357 } else {
358 response =
359 typeof nockDef.response === 'string'
360 ? tryJsonParse(nockDef.response)
361 : nockDef.response
362 }
363
364 const scope = new Scope(nscope, options)
365
366 // If request headers were specified filter by them.
367 Object.entries(reqheaders).forEach(([fieldName, value]) => {
368 scope.matchHeader(fieldName, value)
369 })
370
371 const acceptableFilters = ['filteringRequestBody', 'filteringPath']
372 acceptableFilters.forEach(filter => {
373 if (nockDef[filter]) {
374 scope[filter](nockDef[filter])
375 }
376 })
377
378 scope.intercept(npath, method, body).reply(status, response, rawHeaders)
379
380 scopes.push(scope)
381 })
382
383 return scopes
384}
385
386module.exports = {
387 Scope,
388 load,
389 loadDefs,
390 define,
391}