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