@stuntman/server 0.1.4 → 0.1.6

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 (46) hide show
  1. package/dist/api/api.d.ts +22 -0
  2. package/dist/api/api.js +182 -0
  3. package/dist/api/utils.d.ts +4 -0
  4. package/dist/api/utils.js +60 -0
  5. package/dist/api/validators.d.ts +3 -0
  6. package/dist/api/validators.js +124 -0
  7. package/dist/api/webgui/rules.pug +147 -0
  8. package/dist/api/webgui/style.css +28 -0
  9. package/dist/api/webgui/traffic.pug +37 -0
  10. package/dist/bin/stuntman.d.ts +2 -0
  11. package/dist/bin/stuntman.js +7 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.js +5 -0
  14. package/dist/ipUtils.d.ts +17 -0
  15. package/dist/ipUtils.js +101 -0
  16. package/dist/mock.d.ts +30 -0
  17. package/dist/mock.js +327 -0
  18. package/dist/requestContext.d.ts +9 -0
  19. package/dist/requestContext.js +18 -0
  20. package/dist/ruleExecutor.d.ts +22 -0
  21. package/dist/ruleExecutor.js +187 -0
  22. package/dist/rules/catchAll.d.ts +2 -0
  23. package/dist/rules/catchAll.js +15 -0
  24. package/dist/rules/echo.d.ts +2 -0
  25. package/dist/rules/echo.js +15 -0
  26. package/dist/rules/index.d.ts +3 -0
  27. package/dist/rules/index.js +70 -0
  28. package/dist/storage.d.ts +4 -0
  29. package/dist/storage.js +42 -0
  30. package/package.json +8 -5
  31. package/src/api/api.ts +225 -0
  32. package/src/api/utils.ts +69 -0
  33. package/src/api/validators.ts +132 -0
  34. package/src/api/webgui/rules.pug +147 -0
  35. package/src/api/webgui/style.css +28 -0
  36. package/src/api/webgui/traffic.pug +37 -0
  37. package/src/bin/stuntman.ts +8 -0
  38. package/src/index.ts +1 -0
  39. package/src/ipUtils.ts +83 -0
  40. package/src/mock.ts +382 -0
  41. package/src/requestContext.ts +23 -0
  42. package/src/ruleExecutor.ts +211 -0
  43. package/src/rules/catchAll.ts +14 -0
  44. package/src/rules/echo.ts +14 -0
  45. package/src/rules/index.ts +44 -0
  46. package/src/storage.ts +39 -0
package/src/mock.ts ADDED
@@ -0,0 +1,382 @@
1
+ import { request as fetchRequest } from 'undici';
2
+ import type { Dispatcher } from 'undici';
3
+ import http from 'http';
4
+ import https from 'https';
5
+ import express from 'express';
6
+ import { v4 as uuidv4 } from 'uuid';
7
+ import { getRuleExecutor } from './ruleExecutor';
8
+ import { getTrafficStore } from './storage';
9
+ import { RawHeaders, logger, HttpCode } from '@stuntman/shared';
10
+ import RequestContext from './requestContext';
11
+ import type * as Stuntman from '@stuntman/shared';
12
+ import { IPUtils } from './ipUtils';
13
+ import LRUCache from 'lru-cache';
14
+ import { API } from './api/api';
15
+
16
+ type WithRequiredProperty<Type, Key extends keyof Type> = Type & {
17
+ [Property in Key]-?: Type[Property];
18
+ };
19
+
20
+ const naiveGQLParser = (body: Buffer | string): Stuntman.GQLRequestBody | undefined => {
21
+ try {
22
+ let json: Stuntman.GQLRequestBody | undefined = undefined;
23
+ try {
24
+ json = JSON.parse(Buffer.isBuffer(body) ? body.toString('utf-8') : body);
25
+ } catch (kiss) {
26
+ // and swallow
27
+ }
28
+ if (!json?.query && !json?.operationName) {
29
+ return;
30
+ }
31
+ const lines = json.query
32
+ .split('\n')
33
+ .map((l) => l.replace(/^\s+/g, '').trim())
34
+ .filter((l) => !!l);
35
+ if (/^query /.test(lines[0])) {
36
+ json.type = 'query';
37
+ } else if (/^mutation /.test(lines[0])) {
38
+ json.type = 'mutation';
39
+ } else {
40
+ throw new Error(`Unable to resolve query type of ${lines[0]}`);
41
+ }
42
+ json.methodName = lines[json.operationName ? 1 : 0].split('(')[0].split('{')[0];
43
+ return json;
44
+ } catch (error) {
45
+ logger.debug(error, 'unable to parse GQL');
46
+ }
47
+ return undefined;
48
+ };
49
+
50
+ // TODO add proper web proxy mode
51
+
52
+ export class Mock {
53
+ public readonly mockUuid: string;
54
+ protected options: Stuntman.ServerConfig;
55
+ protected mockApp: express.Express;
56
+ protected MOCK_DOMAIN_REGEX: RegExp;
57
+ protected URL_PORT_REGEX: RegExp;
58
+ protected server: http.Server | null = null;
59
+ protected serverHttps: https.Server | null = null;
60
+ protected trafficStore: LRUCache<string, Stuntman.LogEntry>;
61
+ protected ipUtils: IPUtils | null = null;
62
+ private _api: API | null = null;
63
+ private requestHandler: (req: express.Request, res: express.Response) => Promise<void>;
64
+
65
+ get apiServer() {
66
+ if (this.options.api.disabled) {
67
+ return null;
68
+ }
69
+ if (!this._api) {
70
+ this._api = new API({ ...this.options.api, mockUuid: this.mockUuid }, this.options.webgui);
71
+ }
72
+ return this._api;
73
+ }
74
+
75
+ public get ruleExecutor(): Stuntman.RuleExecutorInterface {
76
+ return getRuleExecutor(this.mockUuid);
77
+ }
78
+
79
+ constructor(options: Stuntman.ServerConfig) {
80
+ this.mockUuid = uuidv4();
81
+ this.options = options;
82
+ if (this.options.mock.httpsPort && (!this.options.mock.httpsKey || !this.options.mock.httpsCert)) {
83
+ throw new Error('missing https key/cert');
84
+ }
85
+
86
+ this.MOCK_DOMAIN_REGEX = new RegExp(
87
+ `(?:\\.([0-9]+))?\\.(?:(?:${this.options.mock.domain}(https?)?)|(?:localhost))(:${this.options.mock.port}${
88
+ this.options.mock.httpsPort ? `|${this.options.mock.httpsPort}` : ''
89
+ })?(?:\\b|$)`,
90
+ 'i'
91
+ );
92
+ this.URL_PORT_REGEX = new RegExp(
93
+ `^(https?:\\/\\/[^:/]+):(?:${this.options.mock.port}${
94
+ this.options.mock.httpsPort ? `|${this.options.mock.httpsPort}` : ''
95
+ })(\\/.*)`,
96
+ 'i'
97
+ );
98
+ this.trafficStore = getTrafficStore(this.mockUuid, this.options.storage.traffic);
99
+ this.ipUtils =
100
+ !this.options.mock.externalDns || this.options.mock.externalDns.length === 0
101
+ ? null
102
+ : new IPUtils({ mockUuid: this.mockUuid, externalDns: this.options.mock.externalDns });
103
+
104
+ this.requestHandler = async (req: express.Request, res: express.Response): Promise<void> => {
105
+ const ctx: RequestContext | null = RequestContext.get(req);
106
+ const requestUuid = ctx?.uuid || uuidv4();
107
+ const timestamp = Date.now();
108
+ const originalHostname = req.headers.host || req.hostname;
109
+ const unproxiedHostname = req.hostname.replace(this.MOCK_DOMAIN_REGEX, '');
110
+ const isProxiedHostname = originalHostname !== unproxiedHostname;
111
+ const originalRequest = {
112
+ id: requestUuid,
113
+ timestamp,
114
+ url: `${req.protocol}://${req.hostname}${req.originalUrl}`,
115
+ method: req.method,
116
+ rawHeaders: new RawHeaders(...req.rawHeaders),
117
+ ...((Buffer.isBuffer(req.body) && { body: req.body.toString('utf-8') }) ||
118
+ (typeof req.body === 'string' && { body: req.body })),
119
+ };
120
+ logger.debug(originalRequest, 'processing request');
121
+ const logContext: Record<string, any> = {
122
+ requestId: originalRequest.id,
123
+ };
124
+ const mockEntry: WithRequiredProperty<Stuntman.LogEntry, 'modifiedRequest'> = {
125
+ originalRequest,
126
+ modifiedRequest: {
127
+ ...this.unproxyRequest(req),
128
+ id: requestUuid,
129
+ timestamp,
130
+ ...(originalRequest.body && { gqlBody: naiveGQLParser(originalRequest.body) }),
131
+ },
132
+ };
133
+ if (!isProxiedHostname) {
134
+ this.removeProxyPort(mockEntry.modifiedRequest);
135
+ }
136
+ const matchingRule = await getRuleExecutor(this.mockUuid).findMatchingRule(mockEntry.modifiedRequest);
137
+ if (matchingRule) {
138
+ mockEntry.mockRuleId = matchingRule.id;
139
+ mockEntry.labels = matchingRule.labels;
140
+ if (matchingRule.actions.mockResponse) {
141
+ const staticResponse =
142
+ typeof matchingRule.actions.mockResponse === 'function'
143
+ ? matchingRule.actions.mockResponse(mockEntry.modifiedRequest)
144
+ : matchingRule.actions.mockResponse;
145
+ mockEntry.modifiedResponse = staticResponse;
146
+ logger.debug({ ...logContext, staticResponse }, 'replying with mocked response');
147
+ if (matchingRule.storeTraffic) {
148
+ this.trafficStore.set(requestUuid, mockEntry);
149
+ }
150
+ if (staticResponse.rawHeaders) {
151
+ for (const header of staticResponse.rawHeaders.toHeaderPairs()) {
152
+ res.setHeader(header[0], header[1]);
153
+ }
154
+ }
155
+ res.status(staticResponse.status || 200);
156
+ res.send(staticResponse.body);
157
+ // static response blocks any further processing
158
+ return;
159
+ }
160
+ if (matchingRule.actions.modifyRequest) {
161
+ mockEntry.modifiedRequest = matchingRule.actions.modifyRequest(mockEntry.modifiedRequest);
162
+ logger.debug({ ...logContext, modifiedRequest: mockEntry.modifiedRequest }, 'modified original request');
163
+ }
164
+ }
165
+ if (this.ipUtils && !isProxiedHostname && !this.ipUtils.isIP(originalHostname)) {
166
+ const hostname = originalHostname.split(':')[0];
167
+ try {
168
+ const internalIPs = await this.ipUtils.resolveIP(hostname);
169
+ if (this.ipUtils.isLocalhostIP(internalIPs) && this.options.mock.externalDns.length) {
170
+ const externalIPs = await this.ipUtils.resolveIP(hostname, { useExternalDns: true });
171
+ logger.debug({ ...logContext, hostname, externalIPs, internalIPs }, 'switched to external IP');
172
+ mockEntry.modifiedRequest.url = mockEntry.modifiedRequest.url.replace(
173
+ /^(https?:\/\/)[^:/]+/i,
174
+ `$1${externalIPs}`
175
+ );
176
+ }
177
+ } catch (error) {
178
+ // swallow the exeception, don't think much can be done at this point
179
+ logger.warn({ ...logContext, error }, `error trying to resolve IP for "${hostname}"`);
180
+ }
181
+ }
182
+
183
+ const originalResponse: Required<Stuntman.Response> = this.options.mock.disableProxy
184
+ ? {
185
+ timestamp: Date.now(),
186
+ body: undefined,
187
+ rawHeaders: new RawHeaders(),
188
+ status: 404,
189
+ }
190
+ : await this.proxyRequest(req, mockEntry, logContext);
191
+
192
+ logger.debug({ ...logContext, originalResponse }, 'received response');
193
+ mockEntry.originalResponse = originalResponse;
194
+ let modifedResponse: Stuntman.Response = {
195
+ ...originalResponse,
196
+ rawHeaders: new RawHeaders(
197
+ ...Array.from(originalResponse.rawHeaders.toHeaderPairs()).flatMap(([key, value]) => {
198
+ // TODO this replace may be too aggressive and doesn't handle protocol (won't be necessary with a trusted cert and mock serving http+https)
199
+ return [
200
+ key,
201
+ isProxiedHostname
202
+ ? value
203
+ : value.replace(
204
+ new RegExp(`(?:^|\\b)(${unproxiedHostname.replace('.', '\\.')})(?:\\b|$)`, 'igm'),
205
+ originalHostname
206
+ ),
207
+ ];
208
+ })
209
+ ),
210
+ };
211
+ if (matchingRule?.actions.modifyResponse) {
212
+ modifedResponse = matchingRule?.actions.modifyResponse(mockEntry.modifiedRequest, originalResponse);
213
+ logger.debug({ ...logContext, modifedResponse }, 'modified response');
214
+ }
215
+
216
+ mockEntry.modifiedResponse = modifedResponse;
217
+ if (matchingRule?.storeTraffic) {
218
+ this.trafficStore.set(requestUuid, mockEntry);
219
+ }
220
+
221
+ if (modifedResponse.status) {
222
+ res.status(modifedResponse.status);
223
+ }
224
+ if (modifedResponse.rawHeaders) {
225
+ for (const header of modifedResponse.rawHeaders.toHeaderPairs()) {
226
+ // since fetch decompresses responses we need to get rid of some headers
227
+ // TODO maybe could be handled better than just skipping, although express should add these back for new body
228
+ // if (/^content-(?:length|encoding)$/i.test(header[0])) {
229
+ // continue;
230
+ // }
231
+ res.setHeader(
232
+ header[0],
233
+ isProxiedHostname ? header[1].replace(unproxiedHostname, originalHostname) : header[1]
234
+ );
235
+ }
236
+ }
237
+ res.end(Buffer.from(modifedResponse.body, 'binary'));
238
+ };
239
+
240
+ this.mockApp = express();
241
+ // TODO for now request body is just a buffer passed further, not inflated
242
+ this.mockApp.use(express.raw({ type: '*/*' }));
243
+
244
+ this.mockApp.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
245
+ RequestContext.bind(req, this.mockUuid);
246
+ next();
247
+ });
248
+
249
+ this.mockApp.all(/.*/, this.requestHandler);
250
+
251
+ this.mockApp.use((error: Error, req: express.Request, res: express.Response) => {
252
+ const ctx: RequestContext | null = RequestContext.get(req);
253
+ const uuid = ctx?.uuid || uuidv4();
254
+ logger.error({ message: error.message, stack: error.stack, name: error.name, uuid }, 'unexpected error');
255
+ if (res) {
256
+ res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
257
+ error: { message: error.message, httpCode: HttpCode.INTERNAL_SERVER_ERROR, uuid },
258
+ });
259
+ return;
260
+ }
261
+ // eslint-disable-next-line no-console
262
+ console.error('mock server encountered a critical error. exiting');
263
+ process.exit(1);
264
+ });
265
+ }
266
+
267
+ private async proxyRequest(
268
+ req: express.Request,
269
+ mockEntry: WithRequiredProperty<Stuntman.LogEntry, 'modifiedRequest'>,
270
+ logContext: any
271
+ ) {
272
+ let controller: AbortController | null = new AbortController();
273
+ const fetchTimeout = setTimeout(() => {
274
+ if (controller) {
275
+ controller.abort(`timeout after ${this.options.mock.timeout}`);
276
+ }
277
+ }, this.options.mock.timeout);
278
+ req.on('close', () => {
279
+ logger.debug(logContext, 'remote client canceled the request');
280
+ clearTimeout(fetchTimeout);
281
+ if (controller) {
282
+ controller.abort('remote client canceled the request');
283
+ }
284
+ });
285
+ let targetResponse: Dispatcher.ResponseData;
286
+ try {
287
+ const requestOptions = {
288
+ headers: mockEntry.modifiedRequest.rawHeaders,
289
+ body: mockEntry.modifiedRequest.body,
290
+ method: mockEntry.modifiedRequest.method.toUpperCase() as Dispatcher.HttpMethod,
291
+ };
292
+ logger.debug(
293
+ {
294
+ ...logContext,
295
+ url: mockEntry.modifiedRequest.url,
296
+ ...requestOptions,
297
+ },
298
+ 'outgoing request attempt'
299
+ );
300
+ targetResponse = await fetchRequest(mockEntry.modifiedRequest.url, requestOptions);
301
+ } catch (error) {
302
+ logger.error({ ...logContext, error, request: mockEntry.modifiedRequest }, 'error fetching');
303
+ throw error;
304
+ } finally {
305
+ controller = null;
306
+ clearTimeout(fetchTimeout);
307
+ }
308
+ const targetResponseBuffer = Buffer.from(await targetResponse.body.arrayBuffer());
309
+ return {
310
+ timestamp: Date.now(),
311
+ body: targetResponseBuffer.toString('binary'),
312
+ status: targetResponse.statusCode,
313
+ rawHeaders: RawHeaders.fromHeadersRecord(targetResponse.headers),
314
+ };
315
+ }
316
+
317
+ public start() {
318
+ if (this.server) {
319
+ throw new Error('mock server already started');
320
+ }
321
+ if (this.options.mock.httpsPort) {
322
+ this.serverHttps = https
323
+ .createServer(
324
+ {
325
+ key: this.options.mock.httpsKey,
326
+ cert: this.options.mock.httpsCert,
327
+ },
328
+ this.mockApp
329
+ )
330
+ .listen(this.options.mock.httpsPort, () => {
331
+ logger.info(`Mock listening on ${this.options.mock.domain}:${this.options.mock.httpsPort}`);
332
+ });
333
+ }
334
+ this.server = this.mockApp.listen(this.options.mock.port, () => {
335
+ logger.info(`Mock listening on ${this.options.mock.domain}:${this.options.mock.port}`);
336
+ if (!this.options.api.disabled) {
337
+ this.apiServer?.start();
338
+ }
339
+ });
340
+ }
341
+
342
+ public stop() {
343
+ if (!this.server) {
344
+ throw new Error('mock server not started');
345
+ }
346
+ if (!this.options.api.disabled) {
347
+ this.apiServer?.stop();
348
+ }
349
+ this.server.close((error) => {
350
+ logger.warn(error, 'problem closing server');
351
+ this.server = null;
352
+ });
353
+ }
354
+
355
+ protected unproxyRequest(req: express.Request): Stuntman.BaseRequest {
356
+ const protocol = (this.MOCK_DOMAIN_REGEX.exec(req.hostname) || [])[2] || req.protocol;
357
+ const port = (this.MOCK_DOMAIN_REGEX.exec(req.hostname) || [])[1] || undefined;
358
+
359
+ // TODO unproxied req might fail if there's a signed url :shrug:
360
+ // but then we can probably switch DNS for some particular 3rd party server to point to mock
361
+ // and in mock have a mapping rule for that domain to point directly to some IP :thinking:
362
+ return {
363
+ url: `${protocol}://${req.hostname.replace(this.MOCK_DOMAIN_REGEX, '')}${port ? `:${port}` : ''}${req.originalUrl}`,
364
+ rawHeaders: new RawHeaders(...req.rawHeaders.map((h) => h.replace(this.MOCK_DOMAIN_REGEX, ''))),
365
+ method: req.method,
366
+ ...(Buffer.isBuffer(req.body) && { body: req.body.toString('utf-8') }),
367
+ };
368
+ }
369
+
370
+ protected removeProxyPort(req: Stuntman.Request): void {
371
+ if (this.URL_PORT_REGEX.test(req.url)) {
372
+ req.url = req.url.replace(this.URL_PORT_REGEX, '$1$2');
373
+ }
374
+ const host = req.rawHeaders.get('host') || '';
375
+ if (
376
+ host.endsWith(`:${this.options.mock.port}`) ||
377
+ (this.options.mock.httpsPort && host.endsWith(`:${this.options.mock.httpsPort}`))
378
+ ) {
379
+ req.rawHeaders.set('host', host.split(':')[0]);
380
+ }
381
+ }
382
+ }
@@ -0,0 +1,23 @@
1
+ import type { Request } from 'express';
2
+ import { v4 as uuidv4 } from 'uuid';
3
+
4
+ export default class RequestContext {
5
+ static _bindings: WeakMap<Request, RequestContext> = new WeakMap<Request, RequestContext>();
6
+
7
+ public readonly mockUuid;
8
+ public readonly uuid;
9
+
10
+ constructor(mockUuid: string) {
11
+ this.uuid = uuidv4();
12
+ this.mockUuid = mockUuid;
13
+ }
14
+
15
+ static bind(req: Request, mockUuid: string): void {
16
+ const ctx = new RequestContext(mockUuid);
17
+ RequestContext._bindings.set(req, ctx);
18
+ }
19
+
20
+ static get(req: Request): RequestContext | null {
21
+ return RequestContext._bindings.get(req) || null;
22
+ }
23
+ }
@@ -0,0 +1,211 @@
1
+ import AwaitLock from 'await-lock';
2
+ import { AppError, DEFAULT_RULE_PRIORITY, HttpCode, logger } from '@stuntman/shared';
3
+ import type * as Stuntman from '@stuntman/shared';
4
+ import { CUSTOM_RULES, DEFAULT_RULES } from './rules';
5
+
6
+ const ruleExecutors: Record<string, RuleExecutor> = {};
7
+
8
+ const transformMockRuleToLive = (rule: Stuntman.Rule): Stuntman.LiveRule => {
9
+ return {
10
+ ...rule,
11
+ counter: 0,
12
+ isEnabled: rule.isEnabled ?? true,
13
+ createdTimestamp: Date.now(),
14
+ };
15
+ };
16
+
17
+ class RuleExecutor implements Stuntman.RuleExecutorInterface {
18
+ // TODO persistent rule storage maybe
19
+ private _rules: Stuntman.LiveRule[];
20
+ private rulesLock = new AwaitLock();
21
+
22
+ private get enabledRules() {
23
+ if (!this._rules) {
24
+ return new Array<Stuntman.LiveRule>();
25
+ }
26
+ const now = Date.now();
27
+ return this._rules
28
+ .filter((r) => (r.isEnabled && !Number.isFinite(r.ttlSeconds)) || r.createdTimestamp + r.ttlSeconds * 1000 > now)
29
+ .sort((a, b) => (a.priority ?? DEFAULT_RULE_PRIORITY) - (b.priority ?? DEFAULT_RULE_PRIORITY));
30
+ }
31
+
32
+ constructor(rules?: Stuntman.Rule[]) {
33
+ this._rules = (rules || []).map(transformMockRuleToLive);
34
+ }
35
+
36
+ private hasExpired() {
37
+ const now = Date.now();
38
+ return this._rules.some((r) => Number.isFinite(r.ttlSeconds) && r.createdTimestamp + r.ttlSeconds * 1000 < now);
39
+ }
40
+
41
+ private async cleanUpExpired() {
42
+ if (!this.hasExpired()) {
43
+ return;
44
+ }
45
+ await this.rulesLock.acquireAsync();
46
+ const now = Date.now();
47
+ try {
48
+ this._rules = this._rules.filter((r) => {
49
+ const shouldKeep = !Number.isFinite(r.ttlSeconds) || r.createdTimestamp + r.ttlSeconds * 1000 > now;
50
+ if (!shouldKeep) {
51
+ logger.debug({ ruleId: r.id }, 'removing expired rule');
52
+ }
53
+ return shouldKeep;
54
+ });
55
+ } finally {
56
+ await this.rulesLock.release();
57
+ }
58
+ }
59
+
60
+ async addRule(rule: Stuntman.Rule, overwrite?: boolean): Promise<Stuntman.LiveRule> {
61
+ await this.cleanUpExpired();
62
+ await this.rulesLock.acquireAsync();
63
+ try {
64
+ if (this._rules.some((r) => r.id === rule.id)) {
65
+ if (!overwrite) {
66
+ throw new AppError({ httpCode: HttpCode.CONFLICT, message: 'rule with given ID already exists' });
67
+ }
68
+ this._removeRule(rule.id);
69
+ }
70
+ const liveRule = transformMockRuleToLive(rule);
71
+ this._rules.push(liveRule);
72
+ logger.debug(liveRule, 'rule added');
73
+ return liveRule;
74
+ } finally {
75
+ await this.rulesLock.release();
76
+ }
77
+ }
78
+
79
+ private _removeRule(ruleOrId: string | Stuntman.Rule) {
80
+ this._rules = this._rules.filter((r) => {
81
+ const notFound = r.id !== (typeof ruleOrId === 'string' ? ruleOrId : ruleOrId.id);
82
+ if (!notFound) {
83
+ logger.debug({ ruleId: r.id }, 'rule removed');
84
+ }
85
+ return notFound;
86
+ });
87
+ }
88
+
89
+ async removeRule(id: string): Promise<void>;
90
+ async removeRule(rule: Stuntman.Rule): Promise<void>;
91
+ async removeRule(ruleOrId: string | Stuntman.Rule): Promise<void> {
92
+ await this.cleanUpExpired();
93
+ await this.rulesLock.acquireAsync();
94
+ try {
95
+ this._removeRule(ruleOrId);
96
+ } finally {
97
+ await this.rulesLock.release();
98
+ }
99
+ }
100
+
101
+ enableRule(id: string): void;
102
+ enableRule(rule: Stuntman.Rule): void;
103
+ enableRule(ruleOrId: string | Stuntman.Rule): void {
104
+ this._rules.forEach((r) => {
105
+ if (r.id === (typeof ruleOrId === 'string' ? ruleOrId : ruleOrId.id)) {
106
+ r.counter = 0;
107
+ r.isEnabled = true;
108
+ logger.debug({ ruleId: r.id }, 'rule enabled');
109
+ }
110
+ });
111
+ }
112
+
113
+ disableRule(id: string): void;
114
+ disableRule(rule: Stuntman.Rule): void;
115
+ disableRule(ruleOrId: string | Stuntman.Rule): void {
116
+ this._rules.forEach((r) => {
117
+ if (r.id === (typeof ruleOrId === 'string' ? ruleOrId : ruleOrId.id)) {
118
+ r.isEnabled = false;
119
+ logger.debug({ ruleId: r.id }, 'rule disabled');
120
+ }
121
+ });
122
+ }
123
+
124
+ async findMatchingRule(request: Stuntman.Request): Promise<Stuntman.LiveRule | null> {
125
+ const logContext: Record<string, any> = {
126
+ requestId: request.id,
127
+ };
128
+ const matchingRule = this.enabledRules.find((rule) => {
129
+ try {
130
+ const matchResult = rule.matches(request);
131
+ logger.trace({ ...logContext, matchResult }, `rule match attempt for ${rule.id}`);
132
+ if (typeof matchResult === 'boolean') {
133
+ return matchResult;
134
+ }
135
+ return matchResult.result;
136
+ } catch (error) {
137
+ logger.error({ ...logContext, ruleId: rule?.id, error }, 'error in rule match function');
138
+ }
139
+ });
140
+ if (!matchingRule) {
141
+ logger.debug(logContext, 'no matching rule found');
142
+ return null;
143
+ }
144
+ const matchResult: Stuntman.RuleMatchResult = matchingRule.matches(request);
145
+ logContext.ruleId = matchingRule.id;
146
+ logger.debug(
147
+ { ...logContext, matchResultMessage: typeof matchResult !== 'boolean' ? matchResult.description : null },
148
+ 'found matching rule'
149
+ );
150
+ const matchingRuleClone = Object.freeze(Object.assign({}, matchingRule));
151
+ ++matchingRule.counter;
152
+ logContext.ruleCounter = matchingRule.counter;
153
+ if (Number.isNaN(matchingRule.counter) || !Number.isFinite(matchingRule.counter)) {
154
+ matchingRule.counter = 0;
155
+ logger.warn(logContext, "it's over 9000!!!");
156
+ }
157
+ // TODO check if that works
158
+ if (matchingRule.disableAfterUse) {
159
+ if (typeof matchingRule.disableAfterUse === 'boolean' || matchingRule.disableAfterUse <= matchingRule.counter) {
160
+ logger.debug(logContext, 'disabling rule for future requests');
161
+ this.disableRule(matchingRule);
162
+ }
163
+ }
164
+ if (matchingRule.removeAfterUse) {
165
+ if (typeof matchingRule.removeAfterUse === 'boolean' || matchingRule.removeAfterUse <= matchingRule.counter) {
166
+ logger.debug(logContext, 'removing rule for future requests');
167
+ await this.removeRule(matchingRule);
168
+ }
169
+ }
170
+ if (typeof matchResult !== 'boolean') {
171
+ if (matchResult.disableRuleIds && matchResult.disableRuleIds.length > 0) {
172
+ logger.debug(
173
+ { ...logContext, disableRuleIds: matchResult.disableRuleIds },
174
+ 'disabling rules based on matchResult'
175
+ );
176
+ for (const ruleId of matchResult.disableRuleIds) {
177
+ this.disableRule(ruleId);
178
+ }
179
+ }
180
+ if (matchResult.enableRuleIds && matchResult.enableRuleIds.length > 0) {
181
+ logger.debug(
182
+ { ...logContext, disableRuleIds: matchResult.disableRuleIds },
183
+ 'enabling rules based on matchResult'
184
+ );
185
+ for (const ruleId of matchResult.enableRuleIds) {
186
+ this.enableRule(ruleId);
187
+ }
188
+ }
189
+ }
190
+ return matchingRuleClone;
191
+ }
192
+
193
+ async getRules(): Promise<readonly Stuntman.LiveRule[]> {
194
+ await this.cleanUpExpired();
195
+ return this._rules;
196
+ }
197
+
198
+ async getRule(id: string): Promise<Stuntman.LiveRule | undefined> {
199
+ await this.cleanUpExpired();
200
+ return this._rules.find((r) => r.id === id);
201
+ }
202
+ }
203
+
204
+ export const getRuleExecutor = (mockUuid: string): RuleExecutor => {
205
+ if (!ruleExecutors[mockUuid]) {
206
+ ruleExecutors[mockUuid] = new RuleExecutor(
207
+ [...DEFAULT_RULES, ...CUSTOM_RULES].map((r) => ({ ...r, ttlSeconds: Infinity }))
208
+ );
209
+ }
210
+ return ruleExecutors[mockUuid];
211
+ };
@@ -0,0 +1,14 @@
1
+ import { CATCH_ALL_RULE_PRIORITY, CATCH_RULE_NAME } from '@stuntman/shared';
2
+ import type * as Stuntman from '@stuntman/shared';
3
+
4
+ export const catchAllRule: Stuntman.DeployedRule = {
5
+ id: CATCH_RULE_NAME,
6
+ matches: () => true,
7
+ priority: CATCH_ALL_RULE_PRIORITY,
8
+ actions: {
9
+ mockResponse: (req: Stuntman.Request) => ({
10
+ body: `Request received by Stuntman mock <pre>${JSON.stringify(req, null, 4)}</pre>`,
11
+ status: 200,
12
+ }),
13
+ },
14
+ };
@@ -0,0 +1,14 @@
1
+ import { DEFAULT_RULE_PRIORITY } from '@stuntman/shared';
2
+ import type * as Stuntman from '@stuntman/shared';
3
+
4
+ export const echoRule: Stuntman.DeployedRule = {
5
+ id: 'internal/echo',
6
+ priority: DEFAULT_RULE_PRIORITY + 1,
7
+ matches: (req: Stuntman.Request) => /https?:\/\/echo\/.*/.test(req.url),
8
+ actions: {
9
+ mockResponse: (req: Stuntman.Request) => ({
10
+ body: req,
11
+ status: 200,
12
+ }),
13
+ },
14
+ };