@mbtest/mountebank 2.9.2-beta.9050

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +94 -0
  3. package/bin/mb +136 -0
  4. package/package.json +71 -0
  5. package/releases.json +52 -0
  6. package/src/cli/api.js +112 -0
  7. package/src/cli/cli.js +420 -0
  8. package/src/controllers/configController.js +64 -0
  9. package/src/controllers/feedController.js +115 -0
  10. package/src/controllers/homeController.js +58 -0
  11. package/src/controllers/imposterController.js +328 -0
  12. package/src/controllers/impostersController.js +215 -0
  13. package/src/controllers/logsController.js +52 -0
  14. package/src/models/behaviors.js +553 -0
  15. package/src/models/behaviorsValidator.js +186 -0
  16. package/src/models/compatibility.js +133 -0
  17. package/src/models/dryRunValidator.js +261 -0
  18. package/src/models/filesystemBackedImpostersRepository.js +908 -0
  19. package/src/models/http/baseHttpServer.js +207 -0
  20. package/src/models/http/headersMap.js +87 -0
  21. package/src/models/http/httpProxy.js +230 -0
  22. package/src/models/http/httpRequest.js +82 -0
  23. package/src/models/http/httpServer.js +18 -0
  24. package/src/models/http/index.js +18 -0
  25. package/src/models/https/cert/mb-cert.pem +20 -0
  26. package/src/models/https/cert/mb-csr.pem +16 -0
  27. package/src/models/https/cert/mb-key.pem +27 -0
  28. package/src/models/https/httpsServer.js +42 -0
  29. package/src/models/https/index.js +18 -0
  30. package/src/models/imposter.js +243 -0
  31. package/src/models/imposterPrinter.js +120 -0
  32. package/src/models/impostersRepository.js +49 -0
  33. package/src/models/inMemoryImpostersRepository.js +418 -0
  34. package/src/models/jsonpath.js +44 -0
  35. package/src/models/mbConnection.js +107 -0
  36. package/src/models/predicates.js +438 -0
  37. package/src/models/protocols.js +242 -0
  38. package/src/models/responseResolver.js +398 -0
  39. package/src/models/smtp/index.js +16 -0
  40. package/src/models/smtp/smtpRequest.js +60 -0
  41. package/src/models/smtp/smtpServer.js +109 -0
  42. package/src/models/tcp/index.js +18 -0
  43. package/src/models/tcp/tcpProxy.js +110 -0
  44. package/src/models/tcp/tcpRequest.js +23 -0
  45. package/src/models/tcp/tcpServer.js +156 -0
  46. package/src/models/tcp/tcpValidator.js +19 -0
  47. package/src/models/xpath.js +95 -0
  48. package/src/mountebank.js +245 -0
  49. package/src/public/images/arrow_down.png +0 -0
  50. package/src/public/images/arrow_up.png +0 -0
  51. package/src/public/images/book.jpg +0 -0
  52. package/src/public/images/dataflow.png +0 -0
  53. package/src/public/images/favicon.ico +0 -0
  54. package/src/public/images/forkme_right_orange_ff7600.png +0 -0
  55. package/src/public/images/mountebank.png +0 -0
  56. package/src/public/images/overview.gif +0 -0
  57. package/src/public/images/quote.png +0 -0
  58. package/src/public/images/tw-logo.png +0 -0
  59. package/src/public/scripts/jquery/jquery-3.6.1.min.js +2 -0
  60. package/src/public/scripts/urlHashHandler.js +31 -0
  61. package/src/public/stylesheets/application.css +424 -0
  62. package/src/public/stylesheets/ie.css +14 -0
  63. package/src/public/stylesheets/imposters.css +121 -0
  64. package/src/public/stylesheets/jqueryui/1.10.4/themes/smoothness/jquery-ui.css +1178 -0
  65. package/src/util/combinators.js +68 -0
  66. package/src/util/date.js +51 -0
  67. package/src/util/errors.js +55 -0
  68. package/src/util/helpers.js +131 -0
  69. package/src/util/inherit.js +28 -0
  70. package/src/util/ip.js +54 -0
  71. package/src/util/logger.js +83 -0
  72. package/src/util/middleware.js +256 -0
  73. package/src/util/scopedLogger.js +47 -0
  74. package/src/views/_footer.ejs +20 -0
  75. package/src/views/_header.ejs +113 -0
  76. package/src/views/_imposter.ejs +8 -0
  77. package/src/views/config.ejs +71 -0
  78. package/src/views/docs/api/behaviors/copy.ejs +427 -0
  79. package/src/views/docs/api/behaviors/decorate.ejs +182 -0
  80. package/src/views/docs/api/behaviors/lookup.ejs +220 -0
  81. package/src/views/docs/api/behaviors/shellTransform.ejs +153 -0
  82. package/src/views/docs/api/behaviors/wait.ejs +121 -0
  83. package/src/views/docs/api/behaviors.ejs +141 -0
  84. package/src/views/docs/api/contracts/addStub-description.ejs +10 -0
  85. package/src/views/docs/api/contracts/addStub.ejs +10 -0
  86. package/src/views/docs/api/contracts/config-description.ejs +32 -0
  87. package/src/views/docs/api/contracts/config.ejs +23 -0
  88. package/src/views/docs/api/contracts/home-description.ejs +18 -0
  89. package/src/views/docs/api/contracts/home.ejs +13 -0
  90. package/src/views/docs/api/contracts/imposter-description.ejs +439 -0
  91. package/src/views/docs/api/contracts/imposter.ejs +182 -0
  92. package/src/views/docs/api/contracts/imposters-description.ejs +13 -0
  93. package/src/views/docs/api/contracts/imposters.ejs +13 -0
  94. package/src/views/docs/api/contracts/logs-description.ejs +3 -0
  95. package/src/views/docs/api/contracts/logs.ejs +14 -0
  96. package/src/views/docs/api/contracts/stub-description.ejs +4 -0
  97. package/src/views/docs/api/contracts/stub.ejs +7 -0
  98. package/src/views/docs/api/contracts/stubs-description.ejs +4 -0
  99. package/src/views/docs/api/contracts/stubs.ejs +11 -0
  100. package/src/views/docs/api/contracts.ejs +133 -0
  101. package/src/views/docs/api/errors.ejs +64 -0
  102. package/src/views/docs/api/fault/connectionReset.ejs +31 -0
  103. package/src/views/docs/api/fault/randomDataThenClose.ejs +31 -0
  104. package/src/views/docs/api/faults.ejs +57 -0
  105. package/src/views/docs/api/injection.ejs +426 -0
  106. package/src/views/docs/api/json.ejs +205 -0
  107. package/src/views/docs/api/jsonpath.ejs +210 -0
  108. package/src/views/docs/api/mocks.ejs +130 -0
  109. package/src/views/docs/api/overview.ejs +968 -0
  110. package/src/views/docs/api/predicates/and.ejs +62 -0
  111. package/src/views/docs/api/predicates/contains.ejs +64 -0
  112. package/src/views/docs/api/predicates/deepEquals.ejs +114 -0
  113. package/src/views/docs/api/predicates/endsWith.ejs +66 -0
  114. package/src/views/docs/api/predicates/equals.ejs +125 -0
  115. package/src/views/docs/api/predicates/exists.ejs +118 -0
  116. package/src/views/docs/api/predicates/inject.ejs +67 -0
  117. package/src/views/docs/api/predicates/matches.ejs +66 -0
  118. package/src/views/docs/api/predicates/not.ejs +52 -0
  119. package/src/views/docs/api/predicates/or.ejs +79 -0
  120. package/src/views/docs/api/predicates/startsWith.ejs +62 -0
  121. package/src/views/docs/api/predicates.ejs +382 -0
  122. package/src/views/docs/api/proxies.ejs +191 -0
  123. package/src/views/docs/api/proxy/addDecorateBehavior.ejs +115 -0
  124. package/src/views/docs/api/proxy/addWaitBehavior.ejs +96 -0
  125. package/src/views/docs/api/proxy/injectHeaders.ejs +91 -0
  126. package/src/views/docs/api/proxy/predicateGenerators.ejs +600 -0
  127. package/src/views/docs/api/proxy/proxyModes.ejs +495 -0
  128. package/src/views/docs/api/stubs.ejs +391 -0
  129. package/src/views/docs/api/xpath.ejs +281 -0
  130. package/src/views/docs/cli/configFiles.ejs +133 -0
  131. package/src/views/docs/cli/customFormatters.ejs +53 -0
  132. package/src/views/docs/cli/help.ejs +6 -0
  133. package/src/views/docs/cli/replay.ejs +42 -0
  134. package/src/views/docs/cli/restart.ejs +10 -0
  135. package/src/views/docs/cli/save.ejs +68 -0
  136. package/src/views/docs/cli/start.ejs +234 -0
  137. package/src/views/docs/cli/stop.ejs +32 -0
  138. package/src/views/docs/commandLine.ejs +93 -0
  139. package/src/views/docs/communityExtensions.ejs +233 -0
  140. package/src/views/docs/gettingStarted.ejs +146 -0
  141. package/src/views/docs/mentalModel.ejs +51 -0
  142. package/src/views/docs/protocols/custom.ejs +231 -0
  143. package/src/views/docs/protocols/http.ejs +238 -0
  144. package/src/views/docs/protocols/https.ejs +246 -0
  145. package/src/views/docs/protocols/smtp.ejs +142 -0
  146. package/src/views/docs/protocols/tcp.ejs +431 -0
  147. package/src/views/docs/security.ejs +38 -0
  148. package/src/views/faqs.ejs +65 -0
  149. package/src/views/feed.ejs +33 -0
  150. package/src/views/imposter.ejs +22 -0
  151. package/src/views/imposters.ejs +33 -0
  152. package/src/views/index.ejs +89 -0
  153. package/src/views/license.ejs +30 -0
  154. package/src/views/logs.ejs +77 -0
  155. package/src/views/releases/v1.1.0.ejs +55 -0
  156. package/src/views/releases/v1.1.36.ejs +84 -0
  157. package/src/views/releases/v1.1.72.ejs +92 -0
  158. package/src/views/releases/v1.10.0.ejs +108 -0
  159. package/src/views/releases/v1.11.0.ejs +109 -0
  160. package/src/views/releases/v1.12.0.ejs +96 -0
  161. package/src/views/releases/v1.13.0.ejs +118 -0
  162. package/src/views/releases/v1.14.0.ejs +107 -0
  163. package/src/views/releases/v1.14.1.ejs +94 -0
  164. package/src/views/releases/v1.15.0.ejs +113 -0
  165. package/src/views/releases/v1.16.0.ejs +104 -0
  166. package/src/views/releases/v1.2.0.ejs +78 -0
  167. package/src/views/releases/v1.2.103.ejs +86 -0
  168. package/src/views/releases/v1.2.122.ejs +86 -0
  169. package/src/views/releases/v1.2.30.ejs +84 -0
  170. package/src/views/releases/v1.2.45.ejs +84 -0
  171. package/src/views/releases/v1.2.56.ejs +79 -0
  172. package/src/views/releases/v1.3.0.ejs +86 -0
  173. package/src/views/releases/v1.3.1.ejs +100 -0
  174. package/src/views/releases/v1.4.0.ejs +96 -0
  175. package/src/views/releases/v1.4.1.ejs +103 -0
  176. package/src/views/releases/v1.4.2.ejs +100 -0
  177. package/src/views/releases/v1.4.3.ejs +113 -0
  178. package/src/views/releases/v1.5.0.ejs +104 -0
  179. package/src/views/releases/v1.5.1.ejs +91 -0
  180. package/src/views/releases/v1.6.0.ejs +109 -0
  181. package/src/views/releases/v1.7.0.ejs +113 -0
  182. package/src/views/releases/v1.7.1.ejs +90 -0
  183. package/src/views/releases/v1.7.2.ejs +96 -0
  184. package/src/views/releases/v1.8.0.ejs +121 -0
  185. package/src/views/releases/v1.9.0.ejs +111 -0
  186. package/src/views/releases/v2.0.0.ejs +159 -0
  187. package/src/views/releases/v2.1.0.ejs +121 -0
  188. package/src/views/releases/v2.1.1.ejs +106 -0
  189. package/src/views/releases/v2.1.2.ejs +84 -0
  190. package/src/views/releases/v2.2.0.ejs +115 -0
  191. package/src/views/releases/v2.2.1.ejs +102 -0
  192. package/src/views/releases/v2.3.0.ejs +121 -0
  193. package/src/views/releases/v2.3.1.ejs +100 -0
  194. package/src/views/releases/v2.3.2.ejs +102 -0
  195. package/src/views/releases/v2.3.3.ejs +97 -0
  196. package/src/views/releases/v2.4.0.ejs +114 -0
  197. package/src/views/releases/v2.5.0.ejs +51 -0
  198. package/src/views/releases/v2.6.0.ejs +35 -0
  199. package/src/views/releases/v2.7.0.ejs +32 -0
  200. package/src/views/releases/v2.8.0.ejs +36 -0
  201. package/src/views/releases/v2.8.1.ejs +7 -0
  202. package/src/views/releases/v2.8.2.ejs +26 -0
  203. package/src/views/releases/v2.9.0.ejs +32 -0
  204. package/src/views/releases/v2.9.1.ejs +10 -0
  205. package/src/views/releases.ejs +26 -0
  206. package/src/views/sitemap.ejs +36 -0
  207. package/src/views/support.ejs +14 -0
@@ -0,0 +1,908 @@
1
+ 'use strict';
2
+
3
+ const fsExtra = require('fs-extra'),
4
+ prometheus = require('prom-client'),
5
+ properLockFile = require('proper-lockfile'),
6
+ pathModule = require('path'),
7
+ helpers = require('../util/helpers.js'),
8
+ errors = require('../util/errors.js');
9
+
10
+ /**
11
+ * An abstraction for loading imposters from the filesystem
12
+ * The root of the database is provided on the command line as --datadir
13
+ * The file layout looks like this:
14
+ *
15
+ * /{datadir}
16
+ * /3000
17
+ * /imposter.json
18
+ * {
19
+ * "protocol": "http",
20
+ * "port": 3000,
21
+ * "stubs: [{
22
+ * "predicates": [{ "equals": { "path": "/" } }],
23
+ * "meta": {
24
+ * "dir": "stubs/{epoch-pid-counter}"
25
+ * }
26
+ * }]
27
+ * }
28
+ *
29
+ * /stubs
30
+ * /{epoch-pid-counter}
31
+ * /meta.json
32
+ * {
33
+ * "responseFiles": ["responses/{epoch-pid-counter}.json"],
34
+ * // An array of indexes into responseFiles which handle repeat behavior
35
+ * "orderWithRepeats": [0],
36
+ * // The next index into orderWithRepeats; incremented with each call to nextResponse()
37
+ * "nextIndex": 0
38
+ * }
39
+ *
40
+ * /responses
41
+ * /{epoch-pid-counter}.json
42
+ * {
43
+ * "is": { "body": "Hello, world!" }
44
+ * }
45
+ *
46
+ * /matches
47
+ * /{epoch-pid-counter}.json
48
+ * { ... }
49
+ *
50
+ * /requests
51
+ * /{epoch-pid-counter}.json
52
+ * { ... }
53
+ *
54
+ * This structure is designed to minimize the amount of file locking and to maximize parallelism and throughput.
55
+ *
56
+ * The imposters.json file needs to be locked during imposter-level activities (e.g. adding a stub).
57
+ * Readers do not lock; they just get the data at the time they read it. Since this file doesn't
58
+ * contain transient state, there's no harm from a stale read. Writes (which happen for
59
+ * stub changing operations, either for proxy response recording or stub API calls) grab a file lock
60
+ * for both the read and the write. Writes to this file should be infrequent, except perhaps during
61
+ * proxy recording. Newly added stubs may change the index of existing stubs in the stubs array, but
62
+ * will never change the stub meta.dir, so it is always safe to hang on to it for subsequent operations.
63
+ *
64
+ * The stub meta.json needs to be locked to add responses or trigger the next response, but is
65
+ * separated from the imposter.json so we can have responses from multiple stubs in parallel with no
66
+ * lock conflict. Again, readers (e.g. to generate imposter JSON) do not need a lock because the responseFiles
67
+ * array is mostly read-only, and even when it's not (adding responses during proxyAlways recording), there's
68
+ * no harm from a stale read. Writers (usually generating the next response for a stub) grab a lock for the
69
+ * read and the write. This should be the most common write across files, which is why the meta.json file
70
+ * is small.
71
+ *
72
+ * In both cases where a file needs to be locked, an exponential backoff retry strategy is used. Inconsistent
73
+ * reads of partially written files (which can happen by default with the system calls - fs.writeFile is not
74
+ * atomic) are avoided by writing first to a temp file (during which time reads can happen to the original file)
75
+ * and then renaming to the original file.
76
+ *
77
+ * New directories and filenames use a timestamp-based filename to allow creating them without synchronizing
78
+ * with a read. Since multiple files (esp. requests) can be added during the same millisecond, a pid and counter
79
+ * is tacked on to the filename to improve uniqueness. It doesn't provide the * ironclad guarantee a GUID
80
+ * does -- two different processes on two different machines could in theory have the same pid and create files
81
+ * during the same timestamp with the same counter, but the odds of that happening * are so small that it's not
82
+ * worth giving up the easy time-based sortability based on filenames alone.
83
+ *
84
+ * Keeping all imposter information under a directory (instead of having metadata outside the directory)
85
+ * allows us to remove the imposter by simply removing the directory.
86
+ *
87
+ * There are some extra checks on filesystem operations (atomicWriteFile) due to antivirus software, solar flares,
88
+ * gremlins, etc. graceful-fs solves some of these, but apparently not all.
89
+ *
90
+ * @module
91
+ */
92
+
93
+ const
94
+ metrics = {
95
+ lockAcquireDuration: new prometheus.Histogram({
96
+ name: 'mb_lock_acquire_duration_seconds',
97
+ help: 'Time it takes to acquire a file lock',
98
+ buckets: [0.1, 0.2, 0.5, 1, 3, 5, 10, 30],
99
+ labelNames: ['caller']
100
+ }),
101
+ lockHoldDuration: new prometheus.Histogram({
102
+ name: 'mb_lock_hold_duration_seconds',
103
+ help: 'Time a file lock is held',
104
+ buckets: [0.1, 0.2, 0.5, 1, 2],
105
+ labelNames: ['caller']
106
+ }),
107
+ lockErrors: new prometheus.Counter({
108
+ name: 'mb_lock_errors_total',
109
+ help: 'Number of lock errors',
110
+ labelNames: ['caller', 'code']
111
+ })
112
+ };
113
+
114
+ /**
115
+ * Creates the repository
116
+ * @param {Object} config - The database configuration
117
+ * @param {String} config.datadir - The database directory
118
+ * @param {Object} logger - The logger
119
+ * @returns {Object}
120
+ */
121
+ function create (config, logger) {
122
+ const imposterFns = {};
123
+ let counter = 0,
124
+ locks = 0;
125
+
126
+ async function ensureDir (filepath) {
127
+ const dir = pathModule.dirname(filepath);
128
+
129
+ await fsExtra.ensureDir(dir);
130
+ }
131
+
132
+ async function ensureFile (filepath) {
133
+ fsExtra.close(await fsExtra.open(filepath, 'as'));
134
+ }
135
+
136
+ async function writeFile (filepath, obj) {
137
+ await ensureDir(filepath);
138
+ await ensureFile(filepath);
139
+ await fsExtra.writeFile(filepath, JSON.stringify(obj, null, 2), {
140
+ flag: 'rs+'
141
+ });
142
+ }
143
+
144
+ function tryParse (maybeJSON, filepath) {
145
+ try {
146
+ return JSON.parse(maybeJSON);
147
+ }
148
+ catch (parseErr) {
149
+ logger.error(`Corrupted database: invalid JSON for ${filepath}`);
150
+ throw errors.DatabaseError(`invalid JSON in ${filepath}`, { details: parseErr.message });
151
+ }
152
+ }
153
+
154
+ async function readFile (filepath, defaultContents) {
155
+ try {
156
+ const data = await fsExtra.readFile(filepath, 'utf8');
157
+ return tryParse(data, filepath);
158
+ }
159
+ catch (err) {
160
+ if (err.code === 'ENOENT') {
161
+ if (typeof defaultContents === 'undefined') {
162
+ logger.error(`Corrupted database: missing file ${filepath}`);
163
+ throw errors.DatabaseError('file not found', { details: err.message });
164
+ }
165
+ else {
166
+ return defaultContents;
167
+ }
168
+ }
169
+ else {
170
+ throw err;
171
+ }
172
+ }
173
+ }
174
+
175
+ function delay (duration) {
176
+ return new Promise(resolve => {
177
+ setTimeout(resolve, duration);
178
+ });
179
+ }
180
+
181
+ async function atomicWriteFile (filepath, obj, attempts = 1) {
182
+ const tmpfile = filepath + '.tmp';
183
+
184
+ try {
185
+ await writeFile(tmpfile, obj);
186
+ await fsExtra.rename(tmpfile, filepath);
187
+ }
188
+ catch (err) {
189
+ if (err.code === 'ENOENT' && attempts < 15) {
190
+ logger.debug(`Attempt ${attempts} failed with ENOENT error on atomic write of ${filepath}. Retrying...`);
191
+ await delay(10);
192
+ await atomicWriteFile(filepath, obj, attempts + 1);
193
+ }
194
+ else {
195
+ throw err;
196
+ }
197
+ }
198
+ }
199
+
200
+ async function lockFile (filepath) {
201
+ const options = {
202
+ realpath: false,
203
+ retries: {
204
+ retries: 20,
205
+ minTimeout: 10,
206
+ maxTimeout: 5000,
207
+ randomize: true,
208
+ factor: 1.5
209
+ },
210
+ stale: 30000
211
+ };
212
+
213
+ // with realpath = false, the file doesn't have to exist, but the directory does
214
+ await ensureDir(filepath);
215
+ return properLockFile.lock(filepath, options);
216
+ }
217
+
218
+ async function readAndWriteFile (filepath, caller, transformer, defaultContents) {
219
+ const currentLockId = locks,
220
+ observeLockAcquireDuration = metrics.lockAcquireDuration.startTimer({ caller });
221
+
222
+ locks += 1;
223
+
224
+ try {
225
+ const release = await lockFile(filepath),
226
+ acquireLockSeconds = observeLockAcquireDuration(),
227
+ observeLockHoldDuration = metrics.lockHoldDuration.startTimer({ caller });
228
+
229
+ logger.debug(`Acquired file lock on ${filepath} for ${caller}-${currentLockId} after ${acquireLockSeconds}s`);
230
+
231
+ const original = await readFile(filepath, defaultContents),
232
+ transformed = await transformer(original);
233
+
234
+ await atomicWriteFile(filepath, transformed);
235
+ await release();
236
+
237
+ const lockHeld = observeLockHoldDuration();
238
+ logger.debug(`Released file lock on ${filepath} for ${caller}-${currentLockId} after ${lockHeld}s`);
239
+ }
240
+ catch (err) {
241
+ // Ignore lock already released errors
242
+ if (err.code !== 'ERELEASED') {
243
+ metrics.lockErrors.inc({ caller, code: err.code });
244
+ properLockFile.unlock(filepath, { realpath: false }).catch(() => { /* ignore */ });
245
+ throw err;
246
+ }
247
+ }
248
+ }
249
+
250
+ async function remove (path) {
251
+ await fsExtra.remove(path);
252
+ }
253
+
254
+ function filenameFor (timestamp) {
255
+ const epoch = timestamp.valueOf();
256
+ counter += 1;
257
+ return `${epoch}-${process.pid}-${counter}`;
258
+ }
259
+
260
+ function partsFrom (filename) {
261
+ // format {epoch}-{pid}-{counter}
262
+ const pattern = /^(\d+)-(\d+)-(\d+)\.json$/,
263
+ parts = pattern.exec(filename);
264
+ return {
265
+ epoch: Number(parts[1]),
266
+ pid: Number(parts[2]),
267
+ counter: Number(parts[3])
268
+ };
269
+ }
270
+
271
+ function timeSorter (first, second) {
272
+ // format {epoch}-{pid}-{counter}
273
+ // sort by epoch first, then pid, then counter to guarantee determinism for
274
+ // files added during the same millisecond.
275
+ const firstParts = partsFrom(first),
276
+ secondParts = partsFrom(second);
277
+ let result = firstParts.epoch - secondParts.epoch;
278
+ if (result === 0) {
279
+ result = firstParts.pid - secondParts.pid;
280
+ }
281
+ if (result === 0) {
282
+ result = firstParts.counter - secondParts.counter;
283
+ }
284
+ return result;
285
+ }
286
+
287
+ function readdir (path) {
288
+ return new Promise((resolve, reject) => {
289
+ fsExtra.readdir(path, (err, files) => {
290
+ if (err && err.code === 'ENOENT') {
291
+ // Nothing saved yet
292
+ resolve([]);
293
+ }
294
+ else if (err) {
295
+ reject(err);
296
+ }
297
+ else {
298
+ resolve(files);
299
+ }
300
+ });
301
+ });
302
+ }
303
+
304
+ async function loadAllInDir (path) {
305
+ const files = await readdir(path),
306
+ reads = files
307
+ .filter(file => file.indexOf('.json') > 0)
308
+ .sort(timeSorter)
309
+ .map(file => readFile(`${path}/${file}`));
310
+
311
+ return Promise.all(reads);
312
+ }
313
+
314
+ function repeatsFor (response) {
315
+ return response.repeat || 1;
316
+ }
317
+
318
+ async function saveStubMetaAndResponses (stub, baseDir) {
319
+ const stubDefinition = {
320
+ meta: { dir: `stubs/${filenameFor(new Date())}` }
321
+ },
322
+ meta = {
323
+ responseFiles: [],
324
+ orderWithRepeats: [],
325
+ nextIndex: 0
326
+ },
327
+ responses = stub.responses || [],
328
+ writes = [];
329
+
330
+ if (stub.predicates) {
331
+ stubDefinition.predicates = stub.predicates;
332
+ }
333
+
334
+ for (let i = 0; i < responses.length; i += 1) {
335
+ const responseFile = `responses/${filenameFor(new Date())}.json`;
336
+ meta.responseFiles.push(responseFile);
337
+
338
+ for (let repeats = 0; repeats < repeatsFor(responses[i]); repeats += 1) {
339
+ meta.orderWithRepeats.push(i);
340
+ }
341
+
342
+ writes.push(writeFile(`${baseDir}/${stubDefinition.meta.dir}/${responseFile}`, responses[i]));
343
+ }
344
+
345
+ writes.push(writeFile(`${baseDir}/${stubDefinition.meta.dir}/meta.json`, meta));
346
+ await Promise.all(writes);
347
+ return stubDefinition;
348
+ }
349
+
350
+ function stubRepository (baseDir) {
351
+ const imposterFile = `${baseDir}/imposter.json`;
352
+
353
+ function metaPath (stubDir) {
354
+ return `${baseDir}/${stubDir}/meta.json`;
355
+ }
356
+
357
+ function responsePath (stubDir, responseFile) {
358
+ return `${baseDir}/${stubDir}/${responseFile}`;
359
+ }
360
+
361
+ function requestPath (request) {
362
+ return `${baseDir}/requests/${filenameFor(Date.parse(request.timestamp))}.json`;
363
+ }
364
+
365
+ function matchPath (stubDir, match) {
366
+ return `${baseDir}/${stubDir}/matches/${filenameFor(Date.parse(match.timestamp))}.json`;
367
+ }
368
+
369
+ function readHeader () {
370
+ return readFile(imposterFile, { stubs: [] });
371
+ }
372
+
373
+ function readAndWriteHeader (caller, transformer) {
374
+ return readAndWriteFile(imposterFile, caller, transformer, { stubs: [] });
375
+ }
376
+
377
+ function wrap (stub) {
378
+ const cloned = helpers.clone(stub || {}),
379
+ stubDir = stub ? stub.meta.dir : '';
380
+
381
+ if (typeof stub === 'undefined') {
382
+ return {
383
+ addResponse: () => Promise.resolve(),
384
+ nextResponse: () => Promise.resolve({
385
+ is: {},
386
+ stubIndex: () => Promise.resolve(0)
387
+ }),
388
+ recordMatch: () => Promise.resolve()
389
+ };
390
+ }
391
+
392
+ delete cloned.meta;
393
+
394
+ /**
395
+ * Adds a response to the stub
396
+ * @memberOf module:models/filesystemBackedImpostersRepository#
397
+ * @param {Object} response - the new response
398
+ * @returns {Object} - the promise
399
+ */
400
+ cloned.addResponse = async response => {
401
+ let responseFile;
402
+ await readAndWriteFile(metaPath(stubDir), 'addResponse', async meta => {
403
+ const responseIndex = meta.responseFiles.length;
404
+ responseFile = `responses/${filenameFor(new Date())}.json`;
405
+
406
+ meta.responseFiles.push(responseFile);
407
+ for (let repeats = 0; repeats < repeatsFor(response); repeats += 1) {
408
+ meta.orderWithRepeats.push(responseIndex);
409
+ }
410
+
411
+ await writeFile(responsePath(stubDir, responseFile), response);
412
+ return meta;
413
+ });
414
+ };
415
+
416
+ async function stubIndex () {
417
+ const header = await readHeader();
418
+ for (let i = 0; i < header.stubs.length; i += 1) {
419
+ if (header.stubs[i].meta.dir === stubDir) {
420
+ return i;
421
+ }
422
+ }
423
+ return 0;
424
+ }
425
+
426
+ function createResponse (responseConfig) {
427
+ const result = helpers.clone(responseConfig || { is: {} });
428
+ result.stubIndex = stubIndex;
429
+ return result;
430
+ }
431
+
432
+ /**
433
+ * Returns the next response for the stub, taking into consideration repeat behavior and cycling back the beginning
434
+ * @memberOf module:models/filesystemBackedImpostersRepository#
435
+ * @returns {Object} - the promise
436
+ */
437
+ cloned.nextResponse = async () => {
438
+ let responseFile;
439
+ await readAndWriteFile(metaPath(stubDir), 'nextResponse', async meta => {
440
+ const maxIndex = meta.orderWithRepeats.length,
441
+ responseIndex = meta.orderWithRepeats[meta.nextIndex % maxIndex];
442
+
443
+ responseFile = meta.responseFiles[responseIndex];
444
+
445
+ meta.nextIndex = (meta.nextIndex + 1) % maxIndex;
446
+ return meta;
447
+ });
448
+
449
+ // No need to read the response file while the lock is held
450
+ const responseConfig = await readFile(responsePath(stubDir, responseFile));
451
+ return createResponse(responseConfig);
452
+ };
453
+
454
+ /**
455
+ * Records a match for debugging purposes
456
+ * @memberOf module:models/filesystemBackedImpostersRepository#
457
+ * @param {Object} request - the request
458
+ * @param {Object} response - the response
459
+ * @param {Object} responseConfig - the config that generated the response
460
+ * @param {Number} processingTime - the time to match the predicate and generate the full response
461
+ * @returns {Object} - the promise
462
+ */
463
+ cloned.recordMatch = async (request, response, responseConfig, processingTime) => {
464
+ const match = {
465
+ timestamp: new Date().toJSON(),
466
+ request,
467
+ response,
468
+ responseConfig,
469
+ processingTime
470
+ };
471
+ await writeFile(matchPath(stubDir, match), match);
472
+ };
473
+
474
+ return cloned;
475
+ }
476
+
477
+ /**
478
+ * Returns the number of stubs for the imposter
479
+ * @memberOf module:models/filesystemBackedImpostersRepository#
480
+ * @returns {Object} - the promise
481
+ */
482
+ async function count () {
483
+ const imposter = await readHeader();
484
+ return imposter.stubs.length;
485
+ }
486
+
487
+ /**
488
+ * Returns the first stub whose predicates matches the filter
489
+ * @memberOf module:models/filesystemBackedImpostersRepository#
490
+ * @param {Function} filter - the filter function
491
+ * @param {Number} startIndex - the index to to start searching
492
+ * @returns {Object} - the promise
493
+ */
494
+ async function first (filter, startIndex = 0) {
495
+ const header = await readHeader();
496
+
497
+ for (let i = startIndex; i < header.stubs.length; i += 1) {
498
+ if (filter(header.stubs[i].predicates || [])) {
499
+ return { success: true, stub: wrap(header.stubs[i]) };
500
+ }
501
+ }
502
+ return { success: false, stub: wrap() };
503
+ }
504
+
505
+ /**
506
+ * Adds a new stub to imposter
507
+ * @memberOf module:models/filesystemBackedImpostersRepository#
508
+ * @param {Object} stub - the stub to add
509
+ * @returns {Object} - the promise
510
+ */
511
+ async function add (stub) { // eslint-disable-line no-shadow
512
+ const stubDefinition = await saveStubMetaAndResponses(stub, baseDir);
513
+
514
+ await readAndWriteHeader('addStub', async header => {
515
+ header.stubs.push(stubDefinition);
516
+ return header;
517
+ });
518
+ }
519
+
520
+ /**
521
+ * Inserts a new stub at the given index
522
+ * @memberOf module:models/filesystemBackedImpostersRepository#
523
+ * @param {Object} stub - the stub to add
524
+ * @param {Number} index - the index to insert the new stub at
525
+ * @returns {Object} - the promise
526
+ */
527
+ async function insertAtIndex (stub, index) {
528
+ const stubDefinition = await saveStubMetaAndResponses(stub, baseDir);
529
+
530
+ await readAndWriteHeader('insertStubAtIndex', async header => {
531
+ header.stubs.splice(index, 0, stubDefinition);
532
+ return header;
533
+ });
534
+ }
535
+
536
+ /**
537
+ * Deletes the stub at the given index
538
+ * @memberOf module:models/filesystemBackedImpostersRepository#
539
+ * @param {Number} index - the index of the stub to delete
540
+ * @returns {Object} - the promise
541
+ */
542
+ async function deleteAtIndex (index) {
543
+ let stubDir;
544
+
545
+ await readAndWriteHeader('deleteStubAtIndex', async header => {
546
+ if (typeof header.stubs[index] === 'undefined') {
547
+ throw errors.MissingResourceError(`no stub at index ${index}`);
548
+ }
549
+
550
+ stubDir = header.stubs[index].meta.dir;
551
+ header.stubs.splice(index, 1);
552
+ return header;
553
+ });
554
+
555
+ await remove(`${baseDir}/${stubDir}`);
556
+ }
557
+
558
+ /**
559
+ * Overwrites all stubs with a new list
560
+ * @memberOf module:models/filesystemBackedImpostersRepository#
561
+ * @param {Object} newStubs - the new list of stubs
562
+ * @returns {Object} - the promise
563
+ */
564
+ async function overwriteAll (newStubs) {
565
+ await readAndWriteHeader('overwriteAllStubs', async header => {
566
+ header.stubs = [];
567
+ await remove(`${baseDir}/stubs`);
568
+ return header;
569
+ });
570
+
571
+ let addSequence = Promise.resolve();
572
+ newStubs.forEach(stub => {
573
+ addSequence = addSequence.then(() => add(stub));
574
+ });
575
+ await addSequence;
576
+ }
577
+
578
+ /**
579
+ * Overwrites the stub at the given index
580
+ * @memberOf module:models/filesystemBackedImpostersRepository#
581
+ * @param {Object} stub - the new stub
582
+ * @param {Number} index - the index of the stub to overwrite
583
+ * @returns {Object} - the promise
584
+ */
585
+ async function overwriteAtIndex (stub, index) {
586
+ await deleteAtIndex(index);
587
+ await insertAtIndex(stub, index);
588
+ }
589
+
590
+ async function loadResponses (stub) {
591
+ const meta = await readFile(metaPath(stub.meta.dir));
592
+ return Promise.all(meta.responseFiles.map(responseFile =>
593
+ readFile(responsePath(stub.meta.dir, responseFile))));
594
+ }
595
+
596
+ async function loadMatches (stub) {
597
+ return loadAllInDir(`${baseDir}/${stub.meta.dir}/matches`);
598
+ }
599
+
600
+ /**
601
+ * Returns a JSON-convertible representation
602
+ * @memberOf module:models/filesystemBackedImpostersRepository#
603
+ * @param {Object} options - The formatting options
604
+ * @param {Boolean} options.debug - If true, includes debug information
605
+ * @returns {Object} - the promise resolving to the JSON object
606
+ */
607
+ async function toJSON (options = {}) {
608
+ const header = await readHeader(),
609
+ responsePromises = header.stubs.map(loadResponses),
610
+ stubResponses = await Promise.all(responsePromises),
611
+ debugPromises = options.debug ? header.stubs.map(loadMatches) : [],
612
+ matches = await Promise.all(debugPromises);
613
+
614
+ header.stubs.forEach((stub, index) => {
615
+ stub.responses = stubResponses[index];
616
+ if (options.debug && matches[index].length > 0) {
617
+ stub.matches = matches[index];
618
+ }
619
+ delete stub.meta;
620
+ });
621
+
622
+ return header.stubs;
623
+ }
624
+
625
+ function isRecordedResponse (response) {
626
+ return response.is && typeof response.is._proxyResponseTime === 'number';
627
+ }
628
+
629
+ /**
630
+ * Removes the saved proxy responses
631
+ * @memberOf module:models/filesystemBackedImpostersRepository#
632
+ * @returns {Object} - Promise
633
+ */
634
+ async function deleteSavedProxyResponses () {
635
+ const allStubs = await toJSON();
636
+ allStubs.forEach(stub => {
637
+ stub.responses = stub.responses.filter(response => !isRecordedResponse(response));
638
+ });
639
+
640
+ const nonProxyStubs = allStubs.filter(stub => stub.responses.length > 0);
641
+ return overwriteAll(nonProxyStubs);
642
+ }
643
+
644
+ /**
645
+ * Adds a request for the imposter
646
+ * @memberOf module:models/filesystemBackedImpostersRepository#
647
+ * @param {Object} request - the request
648
+ * @returns {Object} - the promise
649
+ */
650
+ async function addRequest (request) {
651
+ const recordedRequest = helpers.clone(request);
652
+
653
+ recordedRequest.timestamp = new Date().toJSON();
654
+ await writeFile(requestPath(recordedRequest), recordedRequest);
655
+ }
656
+
657
+ /**
658
+ * Returns the saved requests for the imposter
659
+ * @memberOf module:models/filesystemBackedImpostersRepository#
660
+ * @returns {Object} - the promise resolving to the array of requests
661
+ */
662
+ async function loadRequests () {
663
+ return loadAllInDir(`${baseDir}/requests`);
664
+ }
665
+
666
+ /**
667
+ * Deletes the requests directory for an imposter
668
+ * @memberOf module:models/filesystemBackedImpostersRepository#
669
+ * @returns {Object} - Promise
670
+ */
671
+ async function deleteSavedRequests () {
672
+ await remove(`${baseDir}/requests`);
673
+ }
674
+
675
+ return {
676
+ count,
677
+ first,
678
+ add,
679
+ insertAtIndex,
680
+ overwriteAll,
681
+ overwriteAtIndex,
682
+ deleteAtIndex,
683
+ toJSON,
684
+ deleteSavedProxyResponses,
685
+ addRequest,
686
+ loadRequests,
687
+ deleteSavedRequests
688
+ };
689
+ }
690
+
691
+ function imposterDir (id) {
692
+ return `${config.datadir}/${id}`;
693
+ }
694
+
695
+ function headerFile (id) {
696
+ return `${imposterDir(id)}/imposter.json`;
697
+ }
698
+
699
+ /**
700
+ * Returns the stubs repository for the imposter
701
+ * @memberOf module:models/filesystemBackedImpostersRepository#
702
+ * @param {Number} id - the id of the imposter
703
+ * @returns {Object} - the stubs repository
704
+ */
705
+ function stubsFor (id) {
706
+ return stubRepository(imposterDir(id));
707
+ }
708
+
709
+ /**
710
+ * Saves a reference to the imposter so that the functions
711
+ * (which can't be persisted) can be rehydrated to a loaded imposter.
712
+ * This means that any data in the function closures will be held in
713
+ * memory.
714
+ * @memberOf module:models/filesystemBackedImpostersRepository#
715
+ * @param {Object} imposter - the imposter
716
+ */
717
+ function addReference (imposter) {
718
+ const id = String(imposter.port);
719
+ imposterFns[id] = {};
720
+ Object.keys(imposter).forEach(key => {
721
+ if (typeof imposter[key] === 'function') {
722
+ imposterFns[id][key] = imposter[key];
723
+ }
724
+ });
725
+ }
726
+
727
+ function rehydrate (imposter) {
728
+ const id = String(imposter.port);
729
+ Object.keys(imposterFns[id]).forEach(key => {
730
+ imposter[key] = imposterFns[id][key];
731
+ });
732
+ }
733
+
734
+ /**
735
+ * Adds a new imposter
736
+ * @memberOf module:models/filesystemBackedImpostersRepository#
737
+ * @param {Object} imposter - the imposter to add
738
+ * @returns {Object} - the promise
739
+ */
740
+ async function add (imposter) {
741
+ const imposterConfig = imposter.creationRequest,
742
+ stubs = imposterConfig.stubs || [],
743
+ saveStubs = stubs.map(stub => saveStubMetaAndResponses(stub, imposterDir(imposter.port))),
744
+ stubDefinitions = await Promise.all(saveStubs);
745
+
746
+ delete imposterConfig.requests;
747
+ imposterConfig.port = imposter.port;
748
+ imposterConfig.stubs = stubDefinitions;
749
+ await writeFile(headerFile(imposter.port), imposterConfig);
750
+
751
+ addReference(imposter);
752
+ return imposter;
753
+ }
754
+
755
+ /**
756
+ * Gets the imposter by id
757
+ * @memberOf module:models/filesystemBackedImpostersRepository#
758
+ * @param {Number} id - the id of the imposter (e.g. the port)
759
+ * @returns {Object} - the promise resolving to the imposter
760
+ */
761
+ async function get (id) {
762
+ const header = await readFile(headerFile(id), null);
763
+ if (header === null) {
764
+ return null;
765
+ }
766
+
767
+ header.stubs = await stubsFor(id).toJSON();
768
+ rehydrate(header);
769
+ return header;
770
+ }
771
+
772
+ /**
773
+ * Gets all imposters
774
+ * @memberOf module:models/filesystemBackedImpostersRepository#
775
+ * @returns {Object} - all imposters keyed by port
776
+ */
777
+ async function all () {
778
+ return Promise.all(Object.keys(imposterFns).map(get));
779
+ }
780
+
781
+ /**
782
+ * Returns whether an imposter at the given id exists or not
783
+ * @memberOf module:models/filesystemBackedImpostersRepository#
784
+ * @param {Number} id - the id (e.g. the port)
785
+ * @returns {boolean}
786
+ */
787
+ async function exists (id) {
788
+ return Object.keys(imposterFns).indexOf(String(id)) >= 0;
789
+ }
790
+
791
+ async function shutdown (id) {
792
+ if (typeof imposterFns[String(id)] === 'undefined') {
793
+ return;
794
+ }
795
+
796
+ const stop = imposterFns[String(id)].stop;
797
+ delete imposterFns[String(id)];
798
+ if (stop) {
799
+ await stop();
800
+ }
801
+ }
802
+
803
+ /**
804
+ * Deletes the imposter at the given id
805
+ * @memberOf module:models/filesystemBackedImpostersRepository#
806
+ * @param {Number} id - the id (e.g. the port)
807
+ * @returns {Object} - the deletion promise
808
+ */
809
+ async function del (id) {
810
+ const imposter = await get(id),
811
+ cleanup = [shutdown(id)];
812
+
813
+ if (imposter !== null) {
814
+ cleanup.push(remove(imposterDir(id)));
815
+ }
816
+
817
+ await Promise.all(cleanup);
818
+ return imposter;
819
+ }
820
+
821
+ /**
822
+ * Deletes all imposters; used during testing
823
+ * @memberOf module:models/filesystemBackedImpostersRepository#
824
+ */
825
+ async function stopAll () {
826
+ const promises = Object.keys(imposterFns).map(shutdown);
827
+ await Promise.all(promises);
828
+ }
829
+
830
+ /**
831
+ * Deletes all imposters synchronously; used during shutdown
832
+ * @memberOf module:models/filesystemBackedImpostersRepository#
833
+ */
834
+ function stopAllSync () {
835
+ Object.keys(imposterFns).forEach(shutdown);
836
+ }
837
+
838
+ /**
839
+ * Deletes all imposters
840
+ * @memberOf module:models/filesystemBackedImpostersRepository#
841
+ * @returns {Object} - the deletion promise
842
+ */
843
+ async function deleteAll () {
844
+ const ids = Object.keys(imposterFns),
845
+ dirs = ids.map(imposterDir),
846
+ deleteImposter = ids.map(shutdown).concat(dirs.map(remove));
847
+
848
+ // Remove only the directories for imposters we have a reference to
849
+ await Promise.all(deleteImposter);
850
+ const entries = await readdir(config.datadir);
851
+ if (entries.length === 0) {
852
+ await remove(config.datadir);
853
+ }
854
+ }
855
+
856
+ async function loadImposterFrom (dir, protocols) {
857
+ const imposterFilename = `${config.datadir}/${dir}/imposter.json`;
858
+
859
+ if (!fsExtra.existsSync(imposterFilename)) {
860
+ logger.warn(`Skipping ${dir} during loading; missing imposter.json`);
861
+ return;
862
+ }
863
+
864
+ const imposterConfig = JSON.parse(fsExtra.readFileSync(imposterFilename)),
865
+ protocol = protocols[imposterConfig.protocol];
866
+
867
+ if (protocol) {
868
+ logger.info(`Loading ${imposterConfig.protocol}:${dir} from datadir`);
869
+ const imposter = await protocol.createImposterFrom(imposterConfig);
870
+ addReference(imposter);
871
+ }
872
+ else {
873
+ logger.error(`Cannot load imposter ${dir}; no protocol loaded for ${config.protocol}`);
874
+ }
875
+ }
876
+
877
+ /**
878
+ * Loads all saved imposters at startup
879
+ * @memberOf module:models/filesystemBackedImpostersRepository#
880
+ * @param {Object} protocols - The protocol map, used to instantiate a new instance
881
+ * @returns {Object} - a promise
882
+ */
883
+ async function loadAll (protocols) {
884
+ if (!config.datadir || !fsExtra.existsSync(config.datadir)) {
885
+ return;
886
+ }
887
+
888
+ const dirs = fsExtra.readdirSync(config.datadir),
889
+ promises = dirs.map(async dir => loadImposterFrom(dir, protocols));
890
+ await Promise.all(promises);
891
+ }
892
+
893
+ return {
894
+ add,
895
+ addReference,
896
+ get,
897
+ all,
898
+ exists,
899
+ del,
900
+ stopAll,
901
+ stopAllSync,
902
+ deleteAll,
903
+ stubsFor,
904
+ loadAll
905
+ };
906
+ }
907
+
908
+ module.exports = { create };