@stuntman/server 0.1.3 → 0.1.5

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/dist/api/api.js CHANGED
@@ -26,6 +26,9 @@ class API {
26
26
  this.apiApp.use(express_1.default.json());
27
27
  this.apiApp.use(express_1.default.text());
28
28
  this.auth = (req, type) => {
29
+ if (!this.options.apiKeyReadOnly && !this.options.apiKeyReadWrite) {
30
+ return;
31
+ }
29
32
  const hasValidReadKey = req.header(API_KEY_HEADER) === this.options.apiKeyReadOnly;
30
33
  const hasValidWriteKey = req.header(API_KEY_HEADER) === this.options.apiKeyReadWrite;
31
34
  const hasValidKey = type === 'read' ? hasValidReadKey || hasValidWriteKey : hasValidWriteKey;
@@ -104,7 +107,7 @@ class API {
104
107
  });
105
108
  return;
106
109
  }
107
- shared_1.logger.error({ ...error, uuid }, 'Unexpected error');
110
+ shared_1.logger.error({ message: error.message, stack: error.stack, name: error.name, uuid }, 'unexpected error');
108
111
  if (res) {
109
112
  res.status(shared_1.HttpCode.INTERNAL_SERVER_ERROR).json({
110
113
  error: { message: error.message, httpCode: shared_1.HttpCode.INTERNAL_SERVER_ERROR, uuid },
package/dist/mock.d.ts CHANGED
@@ -18,11 +18,11 @@ export declare class Mock {
18
18
  protected trafficStore: LRUCache<string, Stuntman.LogEntry>;
19
19
  protected ipUtils: IPUtils | null;
20
20
  private _api;
21
+ private requestHandler;
21
22
  get apiServer(): API | null;
22
23
  get ruleExecutor(): Stuntman.RuleExecutorInterface;
23
24
  constructor(options: Stuntman.ServerConfig);
24
25
  private proxyRequest;
25
- private requestHandler;
26
26
  start(): void;
27
27
  stop(): void;
28
28
  protected unproxyRequest(req: express.Request): Stuntman.BaseRequest;
package/dist/mock.js CHANGED
@@ -78,6 +78,120 @@ class Mock {
78
78
  !this.options.mock.externalDns || this.options.mock.externalDns.length === 0
79
79
  ? null
80
80
  : new ipUtils_1.IPUtils({ mockUuid: this.mockUuid, externalDns: this.options.mock.externalDns });
81
+ this.requestHandler = async (req, res) => {
82
+ var _a, _b, _c, _d, _e;
83
+ const ctx = requestContext_1.default.get(req);
84
+ const requestUuid = (ctx === null || ctx === void 0 ? void 0 : ctx.uuid) || (0, uuid_1.v4)();
85
+ const timestamp = Date.now();
86
+ const originalHostname = req.headers.host || req.hostname;
87
+ const unproxiedHostname = req.hostname.replace(this.MOCK_DOMAIN_REGEX, '');
88
+ const isProxiedHostname = originalHostname !== unproxiedHostname;
89
+ const originalRequest = {
90
+ id: requestUuid,
91
+ timestamp,
92
+ url: `${req.protocol}://${req.hostname}${req.originalUrl}`,
93
+ method: req.method,
94
+ rawHeaders: new shared_1.RawHeaders(...req.rawHeaders),
95
+ ...((Buffer.isBuffer(req.body) && { body: req.body.toString('utf-8') }) ||
96
+ (typeof req.body === 'string' && { body: req.body })),
97
+ };
98
+ shared_1.logger.debug(originalRequest, 'processing request');
99
+ const logContext = {
100
+ requestId: originalRequest.id,
101
+ };
102
+ const mockEntry = {
103
+ originalRequest,
104
+ modifiedRequest: {
105
+ ...this.unproxyRequest(req),
106
+ id: requestUuid,
107
+ timestamp,
108
+ ...(originalRequest.body && { gqlBody: naiveGQLParser(originalRequest.body) }),
109
+ },
110
+ };
111
+ if (!isProxiedHostname) {
112
+ this.removeProxyPort(mockEntry.modifiedRequest);
113
+ }
114
+ const matchingRule = await (0, ruleExecutor_1.getRuleExecutor)(this.mockUuid).findMatchingRule(mockEntry.modifiedRequest);
115
+ if (matchingRule) {
116
+ mockEntry.mockRuleId = matchingRule.id;
117
+ mockEntry.labels = matchingRule.labels;
118
+ if ((_a = matchingRule.actions) === null || _a === void 0 ? void 0 : _a.mockResponse) {
119
+ const staticResponse = typeof matchingRule.actions.mockResponse === 'function'
120
+ ? matchingRule.actions.mockResponse(mockEntry.modifiedRequest)
121
+ : matchingRule.actions.mockResponse;
122
+ mockEntry.modifiedResponse = staticResponse;
123
+ shared_1.logger.debug({ ...logContext, staticResponse }, 'replying with mocked response');
124
+ if (matchingRule.storeTraffic) {
125
+ this.trafficStore.set(requestUuid, mockEntry);
126
+ }
127
+ if (staticResponse.rawHeaders) {
128
+ for (const header of staticResponse.rawHeaders.toHeaderPairs()) {
129
+ res.setHeader(header[0], header[1]);
130
+ }
131
+ }
132
+ res.status(staticResponse.status || 200);
133
+ res.send(staticResponse.body);
134
+ // static response blocks any further processing
135
+ return;
136
+ }
137
+ if ((_b = matchingRule.actions) === null || _b === void 0 ? void 0 : _b.modifyRequest) {
138
+ mockEntry.modifiedRequest = (_c = matchingRule.actions) === null || _c === void 0 ? void 0 : _c.modifyRequest(mockEntry.modifiedRequest);
139
+ shared_1.logger.debug({ ...logContext, modifiedRequest: mockEntry.modifiedRequest }, 'modified original request');
140
+ }
141
+ }
142
+ if (this.ipUtils && !isProxiedHostname && !this.ipUtils.isIP(originalHostname)) {
143
+ const hostname = originalHostname.split(':')[0];
144
+ try {
145
+ const internalIPs = await this.ipUtils.resolveIP(hostname);
146
+ if (this.ipUtils.isLocalhostIP(internalIPs) && this.options.mock.externalDns.length) {
147
+ const externalIPs = await this.ipUtils.resolveIP(hostname, { useExternalDns: true });
148
+ shared_1.logger.debug({ ...logContext, hostname, externalIPs, internalIPs }, 'switched to external IP');
149
+ mockEntry.modifiedRequest.url = mockEntry.modifiedRequest.url.replace(/^(https?:\/\/)[^:/]+/i, `$1${externalIPs}`);
150
+ }
151
+ }
152
+ catch (error) {
153
+ // swallow the exeception, don't think much can be done at this point
154
+ shared_1.logger.warn({ ...logContext, error }, `error trying to resolve IP for "${hostname}"`);
155
+ }
156
+ }
157
+ const originalResponse = await this.proxyRequest(req, mockEntry, logContext);
158
+ shared_1.logger.debug({ ...logContext, originalResponse }, 'received response');
159
+ mockEntry.originalResponse = originalResponse;
160
+ let modifedResponse = {
161
+ ...originalResponse,
162
+ rawHeaders: new shared_1.RawHeaders(...Array.from(originalResponse.rawHeaders.toHeaderPairs()).flatMap(([key, value]) => {
163
+ // 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)
164
+ return [
165
+ key,
166
+ isProxiedHostname
167
+ ? value
168
+ : value.replace(new RegExp(`(?:^|\\b)(${unproxiedHostname.replace('.', '\\.')})(?:\\b|$)`, 'igm'), originalHostname),
169
+ ];
170
+ })),
171
+ };
172
+ if ((_d = matchingRule === null || matchingRule === void 0 ? void 0 : matchingRule.actions) === null || _d === void 0 ? void 0 : _d.modifyResponse) {
173
+ modifedResponse = (_e = matchingRule === null || matchingRule === void 0 ? void 0 : matchingRule.actions) === null || _e === void 0 ? void 0 : _e.modifyResponse(mockEntry.modifiedRequest, originalResponse);
174
+ shared_1.logger.debug({ ...logContext, modifedResponse }, 'modified response');
175
+ }
176
+ mockEntry.modifiedResponse = modifedResponse;
177
+ if (matchingRule === null || matchingRule === void 0 ? void 0 : matchingRule.storeTraffic) {
178
+ this.trafficStore.set(requestUuid, mockEntry);
179
+ }
180
+ if (modifedResponse.status) {
181
+ res.status(modifedResponse.status);
182
+ }
183
+ if (modifedResponse.rawHeaders) {
184
+ for (const header of modifedResponse.rawHeaders.toHeaderPairs()) {
185
+ // since fetch decompresses responses we need to get rid of some headers
186
+ // TODO maybe could be handled better than just skipping, although express should add these back for new body
187
+ // if (/^content-(?:length|encoding)$/i.test(header[0])) {
188
+ // continue;
189
+ // }
190
+ res.setHeader(header[0], isProxiedHostname ? header[1].replace(unproxiedHostname, originalHostname) : header[1]);
191
+ }
192
+ }
193
+ res.end(Buffer.from(modifedResponse.body, 'binary'));
194
+ };
81
195
  this.mockApp = (0, express_1.default)();
82
196
  // TODO for now request body is just a buffer passed further, not inflated
83
197
  this.mockApp.use(express_1.default.raw({ type: '*/*' }));
@@ -89,7 +203,7 @@ class Mock {
89
203
  this.mockApp.use((error, req, res, _next) => {
90
204
  const ctx = requestContext_1.default.get(req);
91
205
  const uuid = (ctx === null || ctx === void 0 ? void 0 : ctx.uuid) || (0, uuid_1.v4)();
92
- shared_1.logger.error({ ...error, uuid }, 'Unexpected error');
206
+ shared_1.logger.error({ message: error.message, stack: error.stack, name: error.name, uuid }, 'unexpected error');
93
207
  if (res) {
94
208
  res.status(shared_1.HttpCode.INTERNAL_SERVER_ERROR).json({
95
209
  error: { message: error.message, httpCode: shared_1.HttpCode.INTERNAL_SERVER_ERROR, uuid },
@@ -145,120 +259,6 @@ class Mock {
145
259
  rawHeaders: shared_1.RawHeaders.fromHeadersRecord(targetResponse.headers),
146
260
  };
147
261
  }
148
- async requestHandler(req, res) {
149
- var _a, _b, _c, _d, _e;
150
- const ctx = requestContext_1.default.get(req);
151
- const requestUuid = (ctx === null || ctx === void 0 ? void 0 : ctx.uuid) || (0, uuid_1.v4)();
152
- const timestamp = Date.now();
153
- const originalHostname = req.headers.host || req.hostname;
154
- const unproxiedHostname = req.hostname.replace(this.MOCK_DOMAIN_REGEX, '');
155
- const isProxiedHostname = originalHostname !== unproxiedHostname;
156
- const originalRequest = {
157
- id: requestUuid,
158
- timestamp,
159
- url: `${req.protocol}://${req.hostname}${req.originalUrl}`,
160
- method: req.method,
161
- rawHeaders: new shared_1.RawHeaders(...req.rawHeaders),
162
- ...((Buffer.isBuffer(req.body) && { body: req.body.toString('utf-8') }) ||
163
- (typeof req.body === 'string' && { body: req.body })),
164
- };
165
- shared_1.logger.debug(originalRequest, 'processing request');
166
- const logContext = {
167
- requestId: originalRequest.id,
168
- };
169
- const mockEntry = {
170
- originalRequest,
171
- modifiedRequest: {
172
- ...this.unproxyRequest(req),
173
- id: requestUuid,
174
- timestamp,
175
- ...(originalRequest.body && { gqlBody: naiveGQLParser(originalRequest.body) }),
176
- },
177
- };
178
- if (!isProxiedHostname) {
179
- this.removeProxyPort(mockEntry.modifiedRequest);
180
- }
181
- const matchingRule = await (0, ruleExecutor_1.getRuleExecutor)(this.mockUuid).findMatchingRule(mockEntry.modifiedRequest);
182
- if (matchingRule) {
183
- mockEntry.mockRuleId = matchingRule.id;
184
- mockEntry.labels = matchingRule.labels;
185
- if ((_a = matchingRule.actions) === null || _a === void 0 ? void 0 : _a.mockResponse) {
186
- const staticResponse = typeof matchingRule.actions.mockResponse === 'function'
187
- ? matchingRule.actions.mockResponse(mockEntry.modifiedRequest)
188
- : matchingRule.actions.mockResponse;
189
- mockEntry.modifiedResponse = staticResponse;
190
- shared_1.logger.debug({ ...logContext, staticResponse }, 'replying with mocked response');
191
- if (matchingRule.storeTraffic) {
192
- this.trafficStore.set(requestUuid, mockEntry);
193
- }
194
- if (staticResponse.rawHeaders) {
195
- for (const header of staticResponse.rawHeaders.toHeaderPairs()) {
196
- res.setHeader(header[0], header[1]);
197
- }
198
- }
199
- res.status(staticResponse.status || 200);
200
- res.send(staticResponse.body);
201
- // static response blocks any further processing
202
- return;
203
- }
204
- if ((_b = matchingRule.actions) === null || _b === void 0 ? void 0 : _b.modifyRequest) {
205
- mockEntry.modifiedRequest = (_c = matchingRule.actions) === null || _c === void 0 ? void 0 : _c.modifyRequest(mockEntry.modifiedRequest);
206
- shared_1.logger.debug({ ...logContext, modifiedRequest: mockEntry.modifiedRequest }, 'modified original request');
207
- }
208
- }
209
- if (this.ipUtils && !isProxiedHostname && !this.ipUtils.isIP(originalHostname)) {
210
- const hostname = originalHostname.split(':')[0];
211
- try {
212
- const internalIPs = await this.ipUtils.resolveIP(hostname);
213
- if (this.ipUtils.isLocalhostIP(internalIPs) && this.options.mock.externalDns.length) {
214
- const externalIPs = await this.ipUtils.resolveIP(hostname, { useExternalDns: true });
215
- shared_1.logger.debug({ ...logContext, hostname, externalIPs, internalIPs }, 'switched to external IP');
216
- mockEntry.modifiedRequest.url = mockEntry.modifiedRequest.url.replace(/^(https?:\/\/)[^:/]+/i, `$1${externalIPs}`);
217
- }
218
- }
219
- catch (error) {
220
- // swallow the exeception, don't think much can be done at this point
221
- shared_1.logger.warn({ ...logContext, error }, `error trying to resolve IP for "${hostname}"`);
222
- }
223
- }
224
- const originalResponse = await this.proxyRequest(req, mockEntry, logContext);
225
- shared_1.logger.debug({ ...logContext, originalResponse }, 'received response');
226
- mockEntry.originalResponse = originalResponse;
227
- let modifedResponse = {
228
- ...originalResponse,
229
- rawHeaders: new shared_1.RawHeaders(...Array.from(originalResponse.rawHeaders.toHeaderPairs()).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(new RegExp(`(?:^|\\b)(${unproxiedHostname.replace('.', '\\.')})(?:\\b|$)`, 'igm'), originalHostname),
236
- ];
237
- })),
238
- };
239
- if ((_d = matchingRule === null || matchingRule === void 0 ? void 0 : matchingRule.actions) === null || _d === void 0 ? void 0 : _d.modifyResponse) {
240
- modifedResponse = (_e = matchingRule === null || matchingRule === void 0 ? void 0 : matchingRule.actions) === null || _e === void 0 ? void 0 : _e.modifyResponse(mockEntry.modifiedRequest, originalResponse);
241
- shared_1.logger.debug({ ...logContext, modifedResponse }, 'modified response');
242
- }
243
- mockEntry.modifiedResponse = modifedResponse;
244
- if (matchingRule === null || matchingRule === void 0 ? void 0 : matchingRule.storeTraffic) {
245
- this.trafficStore.set(requestUuid, mockEntry);
246
- }
247
- if (modifedResponse.status) {
248
- res.status(modifedResponse.status);
249
- }
250
- if (modifedResponse.rawHeaders) {
251
- for (const header of modifedResponse.rawHeaders.toHeaderPairs()) {
252
- // since fetch decompresses responses we need to get rid of some headers
253
- // TODO maybe could be handled better than just skipping, although express should add these back for new body
254
- // if (/^content-(?:length|encoding)$/i.test(header[0])) {
255
- // continue;
256
- // }
257
- res.setHeader(header[0], isProxiedHostname ? header[1].replace(unproxiedHostname, originalHostname) : header[1]);
258
- }
259
- }
260
- res.end(Buffer.from(modifedResponse.body, 'binary'));
261
- }
262
262
  start() {
263
263
  if (this.server) {
264
264
  throw new Error('mock server already started');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stuntman/server",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Stuntman - HTTP proxy / mock server with API",
5
5
  "main": "dist/index.js",
6
6
  "repository": {
@@ -57,7 +57,8 @@
57
57
  "stuntman": "./dist/bin/stuntman.js"
58
58
  },
59
59
  "files": [
60
- "dist/",
60
+ "src/**",
61
+ "dist/**",
61
62
  "README.md",
62
63
  "LICENSE",
63
64
  "CHANGELOG.md"
package/src/api/api.ts ADDED
@@ -0,0 +1,231 @@
1
+ import http from 'http';
2
+ import express, { NextFunction, Request, Response, Express as ExpressServer } from 'express';
3
+ import { v4 as uuidv4 } from 'uuid';
4
+ import { getTrafficStore } from '../storage';
5
+ import { getRuleExecutor } from '../ruleExecutor';
6
+ import { logger, AppError, HttpCode, MAX_RULE_TTL_SECONDS, stringify, INDEX_DTS } from '@stuntman/shared';
7
+ import type * as Stuntman from '@stuntman/shared';
8
+ import RequestContext from '../requestContext';
9
+ import serializeJavascript from 'serialize-javascript';
10
+ import LRUCache from 'lru-cache';
11
+ import { validateDeserializedRule } from './validators';
12
+ import { deserializeRule, escapedSerialize, liveRuleToRule } from './utils';
13
+
14
+ type ApiOptions = Stuntman.ApiConfig & {
15
+ mockUuid: string;
16
+ };
17
+
18
+ const API_KEY_HEADER = 'x-api-key';
19
+
20
+ export class API {
21
+ protected options: Required<ApiOptions>;
22
+ protected apiApp: ExpressServer;
23
+ trafficStore: LRUCache<string, Stuntman.LogEntry>;
24
+ server: http.Server | null = null;
25
+ auth: (req: Request, type: 'read' | 'write') => void;
26
+ authReadOnly: (req: Request, res: Response, next: NextFunction) => void;
27
+ authReadWrite: (req: Request, res: Response, next: NextFunction) => void;
28
+
29
+ constructor(options: ApiOptions, webGuiOptions?: Stuntman.WebGuiConfig) {
30
+ if (!options.apiKeyReadOnly !== !options.apiKeyReadWrite) {
31
+ throw new Error('apiKeyReadOnly and apiKeyReadWrite options need to be set either both or none');
32
+ }
33
+ this.options = options;
34
+
35
+ this.trafficStore = getTrafficStore(this.options.mockUuid);
36
+ this.apiApp = express();
37
+
38
+ this.apiApp.use(express.json());
39
+ this.apiApp.use(express.text());
40
+
41
+ this.auth = (req: Request, type: 'read' | 'write'): void => {
42
+ if (!this.options.apiKeyReadOnly && !this.options.apiKeyReadWrite) {
43
+ return;
44
+ }
45
+ const hasValidReadKey = req.header(API_KEY_HEADER) === this.options.apiKeyReadOnly;
46
+ const hasValidWriteKey = req.header(API_KEY_HEADER) === this.options.apiKeyReadWrite;
47
+ const hasValidKey = type === 'read' ? hasValidReadKey || hasValidWriteKey : hasValidWriteKey;
48
+ if (!hasValidKey) {
49
+ throw new AppError({ httpCode: HttpCode.UNAUTHORIZED, message: 'unauthorized' });
50
+ }
51
+ return;
52
+ };
53
+
54
+ this.authReadOnly = (req: Request, res: Response, next: NextFunction): void => {
55
+ this.auth(req, 'read');
56
+ next();
57
+ };
58
+
59
+ this.authReadWrite = (req: Request, res: Response, next: NextFunction): void => {
60
+ this.auth(req, 'write');
61
+ next();
62
+ };
63
+
64
+ this.apiApp.use((req: Request, res: Response, next: NextFunction) => {
65
+ RequestContext.bind(req, this.options.mockUuid);
66
+ next();
67
+ });
68
+
69
+ this.apiApp.get('/rule', this.authReadOnly, async (req, res) => {
70
+ res.send(stringify(await getRuleExecutor(this.options.mockUuid).getRules()));
71
+ });
72
+
73
+ this.apiApp.get('/rule/:ruleId', this.authReadOnly, async (req, res) => {
74
+ res.send(stringify(await getRuleExecutor(this.options.mockUuid).getRule(req.params.ruleId)));
75
+ });
76
+
77
+ this.apiApp.get('/rule/:ruleId/disable', this.authReadWrite, (req, res) => {
78
+ getRuleExecutor(this.options.mockUuid).disableRule(req.params.ruleId);
79
+ res.send();
80
+ });
81
+
82
+ this.apiApp.get('/rule/:ruleId/enable', this.authReadWrite, (req, res) => {
83
+ getRuleExecutor(this.options.mockUuid).enableRule(req.params.ruleId);
84
+ res.send();
85
+ });
86
+
87
+ this.apiApp.post(
88
+ '/rule',
89
+ this.authReadWrite,
90
+ async (req: Request<object, string, Stuntman.SerializedRule>, res: Response) => {
91
+ const deserializedRule = deserializeRule(req.body);
92
+ validateDeserializedRule(deserializedRule);
93
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
94
+ // @ts-ignore
95
+ const rule = await getRuleExecutor(this.options.mockUuid).addRule(deserializedRule);
96
+ res.send(stringify(rule));
97
+ }
98
+ );
99
+
100
+ this.apiApp.get('/rule/:ruleId/remove', this.authReadWrite, async (req, res) => {
101
+ await getRuleExecutor(this.options.mockUuid).removeRule(req.params.ruleId);
102
+ res.send();
103
+ });
104
+
105
+ this.apiApp.get('/traffic', this.authReadOnly, (req, res) => {
106
+ const serializedTraffic: Record<string, Stuntman.LogEntry> = {};
107
+ for (const [key, value] of this.trafficStore.entries()) {
108
+ serializedTraffic[key] = value;
109
+ }
110
+ res.json(serializedTraffic);
111
+ });
112
+
113
+ this.apiApp.get('/traffic/:ruleIdOrLabel', this.authReadOnly, (req, res) => {
114
+ const serializedTraffic: Record<string, Stuntman.LogEntry> = {};
115
+ for (const [key, value] of this.trafficStore.entries()) {
116
+ if (value.mockRuleId === req.params.ruleIdOrLabel || (value.labels || []).includes(req.params.ruleIdOrLabel)) {
117
+ serializedTraffic[key] = value;
118
+ }
119
+ }
120
+ res.json(serializedTraffic);
121
+ });
122
+
123
+ if (!webGuiOptions?.disabled) {
124
+ this.apiApp.set('views', __dirname + '/webgui');
125
+ this.apiApp.set('view engine', 'pug');
126
+ this.initWebGui();
127
+ }
128
+
129
+ this.apiApp.all(/.*/, (req: Request, res: Response) => res.status(404).send());
130
+
131
+ this.apiApp.use((error: Error | AppError, req: Request, res: Response) => {
132
+ const ctx: RequestContext | null = RequestContext.get(req);
133
+ const uuid = ctx?.uuid || uuidv4();
134
+ if (error instanceof AppError && error.isOperational && res) {
135
+ logger.error(error);
136
+ res.status(error.httpCode).json({
137
+ error: { message: error.message, httpCode: error.httpCode, stack: error.stack },
138
+ });
139
+ return;
140
+ }
141
+ logger.error({ message: error.message, stack: error.stack, name: error.name, uuid }, 'unexpected error');
142
+ if (res) {
143
+ res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
144
+ error: { message: error.message, httpCode: HttpCode.INTERNAL_SERVER_ERROR, uuid },
145
+ });
146
+ return;
147
+ }
148
+ // eslint-disable-next-line no-console
149
+ console.log('API server encountered a critical error. Exiting');
150
+ process.exit(1);
151
+ });
152
+ }
153
+
154
+ private initWebGui() {
155
+ this.apiApp.get('/webgui/rules', this.authReadOnly, async (req, res) => {
156
+ const rules: Record<string, string> = {};
157
+ for (const rule of await getRuleExecutor(this.options.mockUuid).getRules()) {
158
+ rules[rule.id] = serializeJavascript(liveRuleToRule(rule), { unsafe: true });
159
+ }
160
+ res.render('rules', { rules: escapedSerialize(rules), INDEX_DTS, ruleKeys: Object.keys(rules) });
161
+ });
162
+
163
+ this.apiApp.get('/webgui/traffic', this.authReadOnly, async (req, res) => {
164
+ const serializedTraffic: Stuntman.LogEntry[] = [];
165
+ for (const value of this.trafficStore.values()) {
166
+ serializedTraffic.push(value);
167
+ }
168
+ res.render('traffic', {
169
+ traffic: JSON.stringify(
170
+ serializedTraffic.sort((a, b) => b.originalRequest.timestamp - a.originalRequest.timestamp)
171
+ ),
172
+ });
173
+ });
174
+
175
+ // TODO make webui way better and safer, nicer formatting, eslint/prettier, blackjack and hookers
176
+
177
+ this.apiApp.post('/webgui/rules/unsafesave', this.authReadWrite, async (req, res) => {
178
+ const rule: Stuntman.Rule = new Function(req.body)();
179
+ if (
180
+ !rule ||
181
+ !rule.id ||
182
+ typeof rule.matches !== 'function' ||
183
+ typeof rule.ttlSeconds !== 'number' ||
184
+ rule.ttlSeconds > MAX_RULE_TTL_SECONDS
185
+ ) {
186
+ throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'Invalid rule' });
187
+ }
188
+ await getRuleExecutor(this.options.mockUuid).addRule(
189
+ {
190
+ id: rule.id,
191
+ matches: rule.matches,
192
+ ttlSeconds: rule.ttlSeconds,
193
+ ...(rule.actions && {
194
+ actions: {
195
+ ...(rule.actions.mockResponse
196
+ ? { mockResponse: rule.actions.mockResponse }
197
+ : { modifyRequest: rule.actions.modifyRequest, modifyResponse: rule.actions.modifyResponse }),
198
+ },
199
+ }),
200
+ ...(rule.disableAfterUse !== undefined && { disableAfterUse: rule.disableAfterUse }),
201
+ ...(rule.isEnabled !== undefined && { isEnabled: rule.isEnabled }),
202
+ ...(rule.labels !== undefined && { labels: rule.labels }),
203
+ ...(rule.priority !== undefined && { priority: rule.priority }),
204
+ ...(rule.removeAfterUse !== undefined && { removeAfterUse: rule.removeAfterUse }),
205
+ ...(rule.storeTraffic !== undefined && { storeTraffic: rule.storeTraffic }),
206
+ },
207
+ true
208
+ );
209
+ res.send();
210
+ });
211
+ }
212
+
213
+ public start() {
214
+ if (this.server) {
215
+ throw new Error('mock server already started');
216
+ }
217
+ this.server = this.apiApp.listen(this.options.port, () => {
218
+ logger.info(`API listening on ${this.options.port}`);
219
+ });
220
+ }
221
+
222
+ public stop() {
223
+ if (!this.server) {
224
+ throw new Error('mock server not started');
225
+ }
226
+ this.server.close((error) => {
227
+ logger.warn(error, 'problem closing server');
228
+ this.server = null;
229
+ });
230
+ }
231
+ }
@@ -0,0 +1,69 @@
1
+ import serializeJavascript from 'serialize-javascript';
2
+ import type * as Stuntman from '@stuntman/shared';
3
+ import { logger } from '@stuntman/shared';
4
+ import { validateSerializedRuleProperties } from './validators';
5
+
6
+ // TODO
7
+ export const deserializeRule = (serializedRule: Stuntman.SerializedRule): Stuntman.Rule => {
8
+ logger.debug(serializedRule, 'attempt to deserialize rule');
9
+ validateSerializedRuleProperties(serializedRule);
10
+ const rule: Stuntman.Rule = {
11
+ id: serializedRule.id,
12
+ matches: (req: Stuntman.Request) => new Function('____arg0', serializedRule.matches.remoteFn)(req),
13
+ ttlSeconds: serializedRule.ttlSeconds,
14
+ ...(serializedRule.disableAfterUse !== undefined && { disableAfterUse: serializedRule.disableAfterUse }),
15
+ ...(serializedRule.removeAfterUse !== undefined && { removeAfterUse: serializedRule.removeAfterUse }),
16
+ ...(serializedRule.labels !== undefined && { labels: serializedRule.labels }),
17
+ ...(serializedRule.priority !== undefined && { priority: serializedRule.priority }),
18
+ ...(serializedRule.isEnabled !== undefined && { isEnabled: serializedRule.isEnabled }),
19
+ ...(serializedRule.storeTraffic !== undefined && { storeTraffic: serializedRule.storeTraffic }),
20
+ };
21
+ if (serializedRule.actions) {
22
+ // TODO store the original localFn and variables sent from client for web UI editing maybe
23
+ if (serializedRule.actions.mockResponse) {
24
+ rule.actions = {
25
+ mockResponse:
26
+ 'remoteFn' in serializedRule.actions.mockResponse
27
+ ? (req: Stuntman.Request) =>
28
+ new Function(
29
+ '____arg0',
30
+ (serializedRule.actions?.mockResponse as Stuntman.SerializedRemotableFunction).remoteFn
31
+ )(req)
32
+ : serializedRule.actions.mockResponse,
33
+ };
34
+ } else {
35
+ rule.actions = {};
36
+ if (serializedRule.actions.modifyRequest) {
37
+ rule.actions.modifyRequest = (req: Stuntman.Request) =>
38
+ new Function(
39
+ '____arg0',
40
+ (serializedRule.actions?.modifyRequest as Stuntman.SerializedRemotableFunction).remoteFn
41
+ )(req);
42
+ }
43
+ if (serializedRule.actions.modifyResponse) {
44
+ rule.actions.modifyResponse = (req: Stuntman.Request, res: Stuntman.Response) =>
45
+ new Function(
46
+ '____arg0',
47
+ '____arg1',
48
+ (serializedRule.actions?.modifyResponse as Stuntman.SerializedRemotableFunction).remoteFn
49
+ )(req, res);
50
+ }
51
+ }
52
+ }
53
+ logger.debug(rule, 'deserialized rule');
54
+ return rule;
55
+ };
56
+
57
+ export const escapedSerialize = (obj: any) =>
58
+ serializeJavascript(obj).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, "\\n'\n+ '");
59
+
60
+ export const liveRuleToRule = (liveRule: Stuntman.LiveRule) => {
61
+ const ruleClone: Stuntman.Rule = { ...liveRule };
62
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
63
+ // @ts-ignore
64
+ delete ruleClone.counter;
65
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
66
+ // @ts-ignore
67
+ delete ruleClone.createdTimestamp;
68
+ return ruleClone;
69
+ };