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