@stuntman/server 0.1.2 → 0.1.3

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/README.md CHANGED
@@ -4,7 +4,7 @@ Stuntman is a proxy/mock server that can be deployed remotely together with your
4
4
 
5
5
  It offers API and client library that can be used for example within E2E functional test scripts to dynamically alter it's behaviour for specific traffic matching set of rules of your definition.
6
6
 
7
- In order to get more familiar with the concept and how to use it please refer to [example app](https://github.com/andrzej-woof/stuntman/tree/master/packages/example#readme)
7
+ In order to get more familiar with the concept and how to use it please refer to [example app](https://github.com/andrzej-woof/stuntman/tree/master/apps/example#readme)
8
8
 
9
9
  > **_NOTE:_** This project is at a very early stage of developement and as such may often contain breaking changes in upcoming releases before reaching stable version 1.0.0
10
10
 
package/dist/api/api.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /// <reference types="node" />
2
2
  import http from 'http';
3
- import { Express as ExpressServer } from 'express';
3
+ import { NextFunction, Request, Response, Express as ExpressServer } from 'express';
4
4
  import type * as Stuntman from '@stuntman/shared';
5
5
  import LRUCache from 'lru-cache';
6
6
  type ApiOptions = Stuntman.ApiConfig & {
@@ -11,6 +11,9 @@ export declare class API {
11
11
  protected apiApp: ExpressServer;
12
12
  trafficStore: LRUCache<string, Stuntman.LogEntry>;
13
13
  server: http.Server | null;
14
+ auth: (req: Request, type: 'read' | 'write') => void;
15
+ authReadOnly: (req: Request, res: Response, next: NextFunction) => void;
16
+ authReadWrite: (req: Request, res: Response, next: NextFunction) => void;
14
17
  constructor(options: ApiOptions, webGuiOptions?: Stuntman.WebGuiConfig);
15
18
  private initWebGui;
16
19
  start(): void;
package/dist/api/api.js CHANGED
@@ -11,54 +11,75 @@ const ruleExecutor_1 = require("../ruleExecutor");
11
11
  const shared_1 = require("@stuntman/shared");
12
12
  const requestContext_1 = __importDefault(require("../requestContext"));
13
13
  const serialize_javascript_1 = __importDefault(require("serialize-javascript"));
14
- const validatiors_1 = require("./validatiors");
14
+ const validators_1 = require("./validators");
15
15
  const utils_1 = require("./utils");
16
+ const API_KEY_HEADER = 'x-api-key';
16
17
  class API {
17
18
  constructor(options, webGuiOptions) {
18
19
  this.server = null;
20
+ if (!options.apiKeyReadOnly !== !options.apiKeyReadWrite) {
21
+ throw new Error('apiKeyReadOnly and apiKeyReadWrite options need to be set either both or none');
22
+ }
19
23
  this.options = options;
20
24
  this.trafficStore = (0, storage_1.getTrafficStore)(this.options.mockUuid);
21
25
  this.apiApp = (0, express_1.default)();
22
26
  this.apiApp.use(express_1.default.json());
23
27
  this.apiApp.use(express_1.default.text());
28
+ this.auth = (req, type) => {
29
+ const hasValidReadKey = req.header(API_KEY_HEADER) === this.options.apiKeyReadOnly;
30
+ const hasValidWriteKey = req.header(API_KEY_HEADER) === this.options.apiKeyReadWrite;
31
+ const hasValidKey = type === 'read' ? hasValidReadKey || hasValidWriteKey : hasValidWriteKey;
32
+ if (!hasValidKey) {
33
+ throw new shared_1.AppError({ httpCode: shared_1.HttpCode.UNAUTHORIZED, message: 'unauthorized' });
34
+ }
35
+ return;
36
+ };
37
+ this.authReadOnly = (req, res, next) => {
38
+ this.auth(req, 'read');
39
+ next();
40
+ };
41
+ this.authReadWrite = (req, res, next) => {
42
+ this.auth(req, 'write');
43
+ next();
44
+ };
24
45
  this.apiApp.use((req, res, next) => {
25
46
  requestContext_1.default.bind(req, this.options.mockUuid);
26
47
  next();
27
48
  });
28
- this.apiApp.get('/rule', async (req, res) => {
49
+ this.apiApp.get('/rule', this.authReadOnly, async (req, res) => {
29
50
  res.send((0, shared_1.stringify)(await (0, ruleExecutor_1.getRuleExecutor)(this.options.mockUuid).getRules()));
30
51
  });
31
- this.apiApp.get('/rule/:ruleId', async (req, res) => {
52
+ this.apiApp.get('/rule/:ruleId', this.authReadOnly, async (req, res) => {
32
53
  res.send((0, shared_1.stringify)(await (0, ruleExecutor_1.getRuleExecutor)(this.options.mockUuid).getRule(req.params.ruleId)));
33
54
  });
34
- this.apiApp.get('/rule/:ruleId/disable', (req, res) => {
55
+ this.apiApp.get('/rule/:ruleId/disable', this.authReadWrite, (req, res) => {
35
56
  (0, ruleExecutor_1.getRuleExecutor)(this.options.mockUuid).disableRule(req.params.ruleId);
36
57
  res.send();
37
58
  });
38
- this.apiApp.get('/rule/:ruleId/enable', (req, res) => {
59
+ this.apiApp.get('/rule/:ruleId/enable', this.authReadWrite, (req, res) => {
39
60
  (0, ruleExecutor_1.getRuleExecutor)(this.options.mockUuid).enableRule(req.params.ruleId);
40
61
  res.send();
41
62
  });
42
- this.apiApp.post('/rule', async (req, res) => {
63
+ this.apiApp.post('/rule', this.authReadWrite, async (req, res) => {
43
64
  const deserializedRule = (0, utils_1.deserializeRule)(req.body);
44
- (0, validatiors_1.validateDeserializedRule)(deserializedRule);
65
+ (0, validators_1.validateDeserializedRule)(deserializedRule);
45
66
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
46
67
  // @ts-ignore
47
68
  const rule = await (0, ruleExecutor_1.getRuleExecutor)(this.options.mockUuid).addRule(deserializedRule);
48
69
  res.send((0, shared_1.stringify)(rule));
49
70
  });
50
- this.apiApp.get('/rule/:ruleId/remove', async (req, res) => {
71
+ this.apiApp.get('/rule/:ruleId/remove', this.authReadWrite, async (req, res) => {
51
72
  await (0, ruleExecutor_1.getRuleExecutor)(this.options.mockUuid).removeRule(req.params.ruleId);
52
73
  res.send();
53
74
  });
54
- this.apiApp.get('/traffic', (req, res) => {
75
+ this.apiApp.get('/traffic', this.authReadOnly, (req, res) => {
55
76
  const serializedTraffic = {};
56
77
  for (const [key, value] of this.trafficStore.entries()) {
57
78
  serializedTraffic[key] = value;
58
79
  }
59
80
  res.json(serializedTraffic);
60
81
  });
61
- this.apiApp.get('/traffic/:ruleIdOrLabel', (req, res) => {
82
+ this.apiApp.get('/traffic/:ruleIdOrLabel', this.authReadOnly, (req, res) => {
62
83
  const serializedTraffic = {};
63
84
  for (const [key, value] of this.trafficStore.entries()) {
64
85
  if (value.mockRuleId === req.params.ruleIdOrLabel || (value.labels || []).includes(req.params.ruleIdOrLabel)) {
@@ -67,7 +88,13 @@ class API {
67
88
  }
68
89
  res.json(serializedTraffic);
69
90
  });
70
- this.apiApp.use((error, req, res, _next) => {
91
+ if (!(webGuiOptions === null || webGuiOptions === void 0 ? void 0 : webGuiOptions.disabled)) {
92
+ this.apiApp.set('views', __dirname + '/webgui');
93
+ this.apiApp.set('view engine', 'pug');
94
+ this.initWebGui();
95
+ }
96
+ this.apiApp.all(/.*/, (req, res) => res.status(404).send());
97
+ this.apiApp.use((error, req, res) => {
71
98
  const ctx = requestContext_1.default.get(req);
72
99
  const uuid = (ctx === null || ctx === void 0 ? void 0 : ctx.uuid) || (0, uuid_1.v4)();
73
100
  if (error instanceof shared_1.AppError && error.isOperational && res) {
@@ -88,21 +115,16 @@ class API {
88
115
  console.log('API server encountered a critical error. Exiting');
89
116
  process.exit(1);
90
117
  });
91
- if (!(webGuiOptions === null || webGuiOptions === void 0 ? void 0 : webGuiOptions.disabled)) {
92
- this.apiApp.set('views', __dirname + '/webgui');
93
- this.apiApp.set('view engine', 'pug');
94
- this.initWebGui();
95
- }
96
118
  }
97
119
  initWebGui() {
98
- this.apiApp.get('/webgui/rules', async (req, res) => {
120
+ this.apiApp.get('/webgui/rules', this.authReadOnly, async (req, res) => {
99
121
  const rules = {};
100
122
  for (const rule of await (0, ruleExecutor_1.getRuleExecutor)(this.options.mockUuid).getRules()) {
101
123
  rules[rule.id] = (0, serialize_javascript_1.default)((0, utils_1.liveRuleToRule)(rule), { unsafe: true });
102
124
  }
103
125
  res.render('rules', { rules: (0, utils_1.escapedSerialize)(rules), INDEX_DTS: shared_1.INDEX_DTS, ruleKeys: Object.keys(rules) });
104
126
  });
105
- this.apiApp.get('/webgui/traffic', async (req, res) => {
127
+ this.apiApp.get('/webgui/traffic', this.authReadOnly, async (req, res) => {
106
128
  const serializedTraffic = [];
107
129
  for (const value of this.trafficStore.values()) {
108
130
  serializedTraffic.push(value);
@@ -112,7 +134,7 @@ class API {
112
134
  });
113
135
  });
114
136
  // TODO make webui way better and safer, nicer formatting, eslint/prettier, blackjack and hookers
115
- this.apiApp.post('/webgui/rules/unsafesave', async (req, res) => {
137
+ this.apiApp.post('/webgui/rules/unsafesave', this.authReadWrite, async (req, res) => {
116
138
  const rule = new Function(req.body)();
117
139
  if (!rule ||
118
140
  !rule.id ||
package/dist/api/utils.js CHANGED
@@ -6,11 +6,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.liveRuleToRule = exports.escapedSerialize = exports.deserializeRule = void 0;
7
7
  const serialize_javascript_1 = __importDefault(require("serialize-javascript"));
8
8
  const shared_1 = require("@stuntman/shared");
9
- const validatiors_1 = require("./validatiors");
9
+ const validators_1 = require("./validators");
10
10
  // TODO
11
11
  const deserializeRule = (serializedRule) => {
12
12
  shared_1.logger.debug(serializedRule, 'attempt to deserialize rule');
13
- (0, validatiors_1.validateSerializedRuleProperties)(serializedRule);
13
+ (0, validators_1.validateSerializedRuleProperties)(serializedRule);
14
14
  const rule = {
15
15
  id: serializedRule.id,
16
16
  matches: (req) => new Function('____arg0', serializedRule.matches.remoteFn)(req),
package/dist/mock.d.ts CHANGED
@@ -21,6 +21,8 @@ export declare class Mock {
21
21
  get apiServer(): API | null;
22
22
  get ruleExecutor(): Stuntman.RuleExecutorInterface;
23
23
  constructor(options: Stuntman.ServerConfig);
24
+ private proxyRequest;
25
+ private requestHandler;
24
26
  start(): void;
25
27
  stop(): void;
26
28
  protected unproxyRequest(req: express.Request): Stuntman.BaseRequest;
package/dist/mock.js CHANGED
@@ -85,161 +85,7 @@ class Mock {
85
85
  requestContext_1.default.bind(req, this.mockUuid);
86
86
  next();
87
87
  });
88
- this.mockApp.all(/.*/, async (req, res) => {
89
- var _a, _b, _c, _d, _e;
90
- const ctx = requestContext_1.default.get(req);
91
- const requestUuid = (ctx === null || ctx === void 0 ? void 0 : ctx.uuid) || (0, uuid_1.v4)();
92
- const timestamp = Date.now();
93
- const originalHostname = req.headers.host || req.hostname;
94
- const unproxiedHostname = req.hostname.replace(this.MOCK_DOMAIN_REGEX, '');
95
- const isProxiedHostname = originalHostname !== unproxiedHostname;
96
- const originalRequest = {
97
- id: requestUuid,
98
- timestamp,
99
- url: `${req.protocol}://${req.hostname}${req.originalUrl}`,
100
- method: req.method,
101
- rawHeaders: new shared_1.RawHeaders(...req.rawHeaders),
102
- ...((Buffer.isBuffer(req.body) && { body: req.body.toString('utf-8') }) ||
103
- (typeof req.body === 'string' && { body: req.body })),
104
- };
105
- shared_1.logger.debug(originalRequest, 'processing request');
106
- const logContext = {
107
- requestId: originalRequest.id,
108
- };
109
- const mockEntry = {
110
- originalRequest,
111
- modifiedRequest: {
112
- ...this.unproxyRequest(req),
113
- id: requestUuid,
114
- timestamp,
115
- ...(originalRequest.body && { gqlBody: naiveGQLParser(originalRequest.body) }),
116
- },
117
- };
118
- if (!isProxiedHostname) {
119
- this.removeProxyPort(mockEntry.modifiedRequest);
120
- }
121
- const matchingRule = await (0, ruleExecutor_1.getRuleExecutor)(this.mockUuid).findMatchingRule(mockEntry.modifiedRequest);
122
- if (matchingRule) {
123
- mockEntry.mockRuleId = matchingRule.id;
124
- mockEntry.labels = matchingRule.labels;
125
- if ((_a = matchingRule.actions) === null || _a === void 0 ? void 0 : _a.mockResponse) {
126
- const staticResponse = typeof matchingRule.actions.mockResponse === 'function'
127
- ? matchingRule.actions.mockResponse(mockEntry.modifiedRequest)
128
- : matchingRule.actions.mockResponse;
129
- mockEntry.modifiedResponse = staticResponse;
130
- shared_1.logger.debug({ ...logContext, staticResponse }, 'replying with mocked response');
131
- if (matchingRule.storeTraffic) {
132
- this.trafficStore.set(requestUuid, mockEntry);
133
- }
134
- if (staticResponse.rawHeaders) {
135
- for (const header of staticResponse.rawHeaders.toHeaderPairs()) {
136
- res.setHeader(header[0], header[1]);
137
- }
138
- }
139
- res.status(staticResponse.status || 200);
140
- res.send(staticResponse.body);
141
- // static response blocks any further processing
142
- return;
143
- }
144
- if ((_b = matchingRule.actions) === null || _b === void 0 ? void 0 : _b.modifyRequest) {
145
- mockEntry.modifiedRequest = (_c = matchingRule.actions) === null || _c === void 0 ? void 0 : _c.modifyRequest(mockEntry.modifiedRequest);
146
- shared_1.logger.debug({ ...logContext, modifiedRequest: mockEntry.modifiedRequest }, 'modified original request');
147
- }
148
- }
149
- if (this.ipUtils && !isProxiedHostname && !this.ipUtils.isIP(originalHostname)) {
150
- const hostname = originalHostname.split(':')[0];
151
- try {
152
- const internalIPs = await this.ipUtils.resolveIP(hostname);
153
- if (this.ipUtils.isLocalhostIP(internalIPs) && this.options.mock.externalDns.length) {
154
- const externalIPs = await this.ipUtils.resolveIP(hostname, { useExternalDns: true });
155
- shared_1.logger.debug({ ...logContext, hostname, externalIPs, internalIPs }, 'switched to external IP');
156
- mockEntry.modifiedRequest.url = mockEntry.modifiedRequest.url.replace(/^(https?:\/\/)[^:/]+/i, `$1${externalIPs}`);
157
- }
158
- }
159
- catch (error) {
160
- // swallow the exeception, don't think much can be done at this point
161
- shared_1.logger.warn({ ...logContext, error }, `error trying to resolve IP for "${hostname}"`);
162
- }
163
- }
164
- let controller = new AbortController();
165
- const fetchTimeout = setTimeout(() => {
166
- if (controller) {
167
- controller.abort(`timeout after ${this.options.mock.timeout}`);
168
- }
169
- }, this.options.mock.timeout);
170
- req.on('close', () => {
171
- shared_1.logger.debug(logContext, 'remote client canceled the request');
172
- clearTimeout(fetchTimeout);
173
- if (controller) {
174
- controller.abort('remote client canceled the request');
175
- }
176
- });
177
- let targetResponse;
178
- try {
179
- const requestOptions = {
180
- headers: mockEntry.modifiedRequest.rawHeaders,
181
- body: mockEntry.modifiedRequest.body,
182
- method: mockEntry.modifiedRequest.method.toUpperCase(),
183
- };
184
- shared_1.logger.debug({
185
- ...logContext,
186
- url: mockEntry.modifiedRequest.url,
187
- ...requestOptions,
188
- }, 'outgoing request attempt');
189
- targetResponse = await (0, undici_1.request)(mockEntry.modifiedRequest.url, requestOptions);
190
- }
191
- catch (error) {
192
- shared_1.logger.error({ ...logContext, error, request: mockEntry.modifiedRequest }, 'error fetching');
193
- throw error;
194
- }
195
- finally {
196
- controller = null;
197
- clearTimeout(fetchTimeout);
198
- }
199
- const targetResponseBuffer = Buffer.from(await targetResponse.body.arrayBuffer());
200
- const originalResponse = {
201
- timestamp: Date.now(),
202
- body: targetResponseBuffer.toString('binary'),
203
- status: targetResponse.statusCode,
204
- rawHeaders: shared_1.RawHeaders.fromHeadersRecord(targetResponse.headers),
205
- };
206
- shared_1.logger.debug({ ...logContext, originalResponse }, 'received response');
207
- mockEntry.originalResponse = originalResponse;
208
- let modifedResponse = {
209
- ...originalResponse,
210
- rawHeaders: new shared_1.RawHeaders(...Array.from(originalResponse.rawHeaders.toHeaderPairs()).flatMap(([key, value]) => {
211
- // 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)
212
- return [
213
- key,
214
- isProxiedHostname
215
- ? value
216
- : value.replace(new RegExp(`(?:^|\\b)(${unproxiedHostname.replace('.', '\\.')})(?:\\b|$)`, 'igm'), originalHostname),
217
- ];
218
- })),
219
- };
220
- if ((_d = matchingRule === null || matchingRule === void 0 ? void 0 : matchingRule.actions) === null || _d === void 0 ? void 0 : _d.modifyResponse) {
221
- modifedResponse = (_e = matchingRule === null || matchingRule === void 0 ? void 0 : matchingRule.actions) === null || _e === void 0 ? void 0 : _e.modifyResponse(mockEntry.modifiedRequest, originalResponse);
222
- shared_1.logger.debug({ ...logContext, modifedResponse }, 'modified response');
223
- }
224
- mockEntry.modifiedResponse = modifedResponse;
225
- if (matchingRule === null || matchingRule === void 0 ? void 0 : matchingRule.storeTraffic) {
226
- this.trafficStore.set(requestUuid, mockEntry);
227
- }
228
- if (modifedResponse.status) {
229
- res.status(modifedResponse.status);
230
- }
231
- if (modifedResponse.rawHeaders) {
232
- for (const header of modifedResponse.rawHeaders.toHeaderPairs()) {
233
- // since fetch decompresses responses we need to get rid of some headers
234
- // TODO maybe could be handled better than just skipping, although express should add these back for new body
235
- if (/^content-(?:length|encoding)$/i.test(header[0])) {
236
- continue;
237
- }
238
- res.setHeader(header[0], isProxiedHostname ? header[1].replace(unproxiedHostname, originalHostname) : header[1]);
239
- }
240
- }
241
- res.end(Buffer.from(modifedResponse.body, 'binary'));
242
- });
88
+ this.mockApp.all(/.*/, this.requestHandler);
243
89
  this.mockApp.use((error, req, res, _next) => {
244
90
  const ctx = requestContext_1.default.get(req);
245
91
  const uuid = (ctx === null || ctx === void 0 ? void 0 : ctx.uuid) || (0, uuid_1.v4)();
@@ -255,6 +101,164 @@ class Mock {
255
101
  process.exit(1);
256
102
  });
257
103
  }
104
+ async proxyRequest(req, mockEntry, logContext) {
105
+ let controller = new AbortController();
106
+ const fetchTimeout = setTimeout(() => {
107
+ if (controller) {
108
+ controller.abort(`timeout after ${this.options.mock.timeout}`);
109
+ }
110
+ }, this.options.mock.timeout);
111
+ req.on('close', () => {
112
+ shared_1.logger.debug(logContext, 'remote client canceled the request');
113
+ clearTimeout(fetchTimeout);
114
+ if (controller) {
115
+ controller.abort('remote client canceled the request');
116
+ }
117
+ });
118
+ let targetResponse;
119
+ try {
120
+ const requestOptions = {
121
+ headers: mockEntry.modifiedRequest.rawHeaders,
122
+ body: mockEntry.modifiedRequest.body,
123
+ method: mockEntry.modifiedRequest.method.toUpperCase(),
124
+ };
125
+ shared_1.logger.debug({
126
+ ...logContext,
127
+ url: mockEntry.modifiedRequest.url,
128
+ ...requestOptions,
129
+ }, 'outgoing request attempt');
130
+ targetResponse = await (0, undici_1.request)(mockEntry.modifiedRequest.url, requestOptions);
131
+ }
132
+ catch (error) {
133
+ shared_1.logger.error({ ...logContext, error, request: mockEntry.modifiedRequest }, 'error fetching');
134
+ throw error;
135
+ }
136
+ finally {
137
+ controller = null;
138
+ clearTimeout(fetchTimeout);
139
+ }
140
+ const targetResponseBuffer = Buffer.from(await targetResponse.body.arrayBuffer());
141
+ return {
142
+ timestamp: Date.now(),
143
+ body: targetResponseBuffer.toString('binary'),
144
+ status: targetResponse.statusCode,
145
+ rawHeaders: shared_1.RawHeaders.fromHeadersRecord(targetResponse.headers),
146
+ };
147
+ }
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
+ }
258
262
  start() {
259
263
  if (this.server) {
260
264
  throw new Error('mock server already started');
@@ -95,6 +95,7 @@ class RuleExecutor {
95
95
  enableRule(ruleOrId) {
96
96
  this._rules.forEach((r) => {
97
97
  if (r.id === (typeof ruleOrId === 'string' ? ruleOrId : ruleOrId.id)) {
98
+ r.counter = 0;
98
99
  r.isEnabled = true;
99
100
  shared_1.logger.debug({ ruleId: r.id }, 'rule enabled');
100
101
  }
@@ -139,6 +140,7 @@ class RuleExecutor {
139
140
  matchingRule.counter = 0;
140
141
  shared_1.logger.warn(logContext, "it's over 9000!!!");
141
142
  }
143
+ // TODO check if that works
142
144
  if (matchingRule.disableAfterUse) {
143
145
  if (typeof matchingRule.disableAfterUse === 'boolean' || matchingRule.disableAfterUse <= matchingRule.counter) {
144
146
  shared_1.logger.debug(logContext, 'disabling rule for future requests');
@@ -148,7 +150,7 @@ class RuleExecutor {
148
150
  if (matchingRule.removeAfterUse) {
149
151
  if (typeof matchingRule.removeAfterUse === 'boolean' || matchingRule.removeAfterUse <= matchingRule.counter) {
150
152
  shared_1.logger.debug(logContext, 'removing rule for future requests');
151
- this.removeRule(matchingRule);
153
+ await this.removeRule(matchingRule);
152
154
  }
153
155
  }
154
156
  if (typeof matchResult !== 'boolean') {
@@ -178,7 +180,7 @@ class RuleExecutor {
178
180
  }
179
181
  const getRuleExecutor = (mockUuid) => {
180
182
  if (!ruleExecutors[mockUuid]) {
181
- ruleExecutors[mockUuid] = new RuleExecutor(rules_1.DEFAULT_RULES.map((r) => ({ ...r, ttlSeconds: Infinity })));
183
+ ruleExecutors[mockUuid] = new RuleExecutor([...rules_1.DEFAULT_RULES, ...rules_1.CUSTOM_RULES].map((r) => ({ ...r, ttlSeconds: Infinity })));
182
184
  }
183
185
  return ruleExecutors[mockUuid];
184
186
  };
@@ -1,2 +1,3 @@
1
1
  import type * as Stuntman from '@stuntman/shared';
2
2
  export declare const DEFAULT_RULES: Stuntman.DeployedRule[];
3
+ export declare const CUSTOM_RULES: Stuntman.DeployedRule[];
@@ -1,7 +1,70 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ var __importDefault = (this && this.__importDefault) || function (mod) {
26
+ return (mod && mod.__esModule) ? mod : { "default": mod };
27
+ };
2
28
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DEFAULT_RULES = void 0;
29
+ exports.CUSTOM_RULES = exports.DEFAULT_RULES = void 0;
30
+ const fs_1 = __importDefault(require("fs"));
31
+ const glob_1 = __importDefault(require("glob"));
32
+ const tsImport = __importStar(require("ts-import"));
4
33
  const catchAll_1 = require("./catchAll");
5
34
  const echo_1 = require("./echo");
6
- // TODO add option to load rules additional default rules from some nice configurable folder
35
+ const shared_1 = require("@stuntman/shared");
7
36
  exports.DEFAULT_RULES = [catchAll_1.catchAllRule, echo_1.echoRule];
37
+ exports.CUSTOM_RULES = [];
38
+ const loadAdditionalRules = () => {
39
+ if (!shared_1.serverConfig.mock.rulesPath || !fs_1.default.existsSync(shared_1.serverConfig.mock.rulesPath)) {
40
+ shared_1.logger.debug({ rulesPath: shared_1.serverConfig.mock.rulesPath }, `additional rules directory not found`);
41
+ return;
42
+ }
43
+ shared_1.logger.debug({ rulesPath: shared_1.serverConfig.mock.rulesPath }, `loading additional rules`);
44
+ const filePaths = glob_1.default.sync('*.[tj]s', { absolute: true, cwd: shared_1.serverConfig.mock.rulesPath });
45
+ for (const filePath of filePaths) {
46
+ // TODO add .ts rule support
47
+ try {
48
+ const loadedFile = /\.js$/.test(filePath) ? require(filePath) : tsImport.loadSync(filePath);
49
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
50
+ const exportedRules = Object.values(loadedFile).filter((rule) => {
51
+ if (!rule || !rule.id || typeof rule.matches !== 'function') {
52
+ shared_1.logger.error({ filePath, rule }, 'invalid exported rule');
53
+ return false;
54
+ }
55
+ return true;
56
+ });
57
+ exports.CUSTOM_RULES.push(...exportedRules);
58
+ }
59
+ catch (error) {
60
+ shared_1.logger.error({ filePath, error }, 'error importing rule');
61
+ }
62
+ }
63
+ const ruleIds = [...exports.DEFAULT_RULES, ...exports.CUSTOM_RULES].map((rule) => rule.id);
64
+ const duplicatedRuleIds = ruleIds.filter((currentValue, currentIndex) => ruleIds.indexOf(currentValue) !== currentIndex);
65
+ if (duplicatedRuleIds.length > 0) {
66
+ shared_1.logger.error({ duplicatedRuleIds }, 'duplicated rule ids');
67
+ throw new Error('duplicated rule ids');
68
+ }
69
+ };
70
+ loadAdditionalRules();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stuntman/server",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Stuntman - HTTP proxy / mock server with API",
5
5
  "main": "dist/index.js",
6
6
  "repository": {
@@ -35,16 +35,20 @@
35
35
  "@stuntman/shared": "^0.1.2",
36
36
  "await-lock": "2.2.2",
37
37
  "express": "5.0.0-beta.1",
38
+ "glob": "8.1.0",
38
39
  "lru-cache": "7.16.0",
39
40
  "object-sizeof": "2.6.1",
40
41
  "pug": "3.0.2",
41
42
  "serialize-javascript": "6.0.1",
43
+ "ts-import": "4.0.0-beta.10",
44
+ "typescript": "4.9.5",
42
45
  "undici": "5.20.0",
43
46
  "uuid": "9.0.0"
44
47
  },
45
48
  "devDependencies": {
46
49
  "@prettier/plugin-pug": "2.4.1",
47
50
  "@types/express": "4.17.17",
51
+ "@types/glob": "8.1.0",
48
52
  "@types/serialize-javascript": "5.0.2",
49
53
  "@types/uuid": "9.0.0",
50
54
  "prettier": "2.8.4"
@@ -63,7 +67,7 @@
63
67
  "clean": "rm -fr dist",
64
68
  "build": "tsc && cp -rv src/api/webgui dist/api",
65
69
  "lint": "prettier --check . && eslint . --ext ts",
66
- "lint:fix": "prettier --write ./src && eslint ./src --ext ts --fix",
70
+ "lint:fix": "prettier --write ./{src,test} && eslint ./{src,test} --ext ts --fix",
67
71
  "start": "node ./dist/bin/stuntman.js",
68
72
  "start:dev": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' ./src/bin/stuntman.ts",
69
73
  "start:debug": "node --inspect-brk=0.0.0.0 ./node_modules/.bin/ts-node --transpile-only ./src/bin/stuntman.ts"
File without changes
File without changes