UNPKG

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