@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 +1 -1
- package/dist/api/api.d.ts +4 -1
- package/dist/api/api.js +41 -19
- package/dist/api/utils.js +2 -2
- package/dist/mock.d.ts +2 -0
- package/dist/mock.js +159 -155
- package/dist/ruleExecutor.js +4 -2
- package/dist/rules/index.d.ts +1 -0
- package/dist/rules/index.js +65 -2
- package/package.json +6 -2
- /package/dist/api/{validatiors.d.ts → validators.d.ts} +0 -0
- /package/dist/api/{validatiors.js → validators.js} +0 -0
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/
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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,
|
|
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(/.*/,
|
|
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');
|
package/dist/ruleExecutor.js
CHANGED
|
@@ -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
|
};
|
package/dist/rules/index.d.ts
CHANGED
package/dist/rules/index.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|