@stuntman/server 0.1.6 → 0.1.8
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 +3 -3
- package/dist/api/api.d.ts +7 -4
- package/dist/api/api.d.ts.map +1 -0
- package/dist/api/api.js +58 -28
- package/dist/api/api.js.map +1 -0
- package/dist/api/utils.d.ts +1 -0
- package/dist/api/utils.d.ts.map +1 -0
- package/dist/api/utils.js +1 -0
- package/dist/api/utils.js.map +1 -0
- package/dist/api/validators.d.ts +1 -0
- package/dist/api/validators.d.ts.map +1 -0
- package/dist/api/validators.js +1 -0
- package/dist/api/validators.js.map +1 -0
- package/dist/api/webgui/rules.pug +2 -2
- package/dist/api/webgui/style.css +3 -3
- package/dist/api/webgui/traffic.pug +1 -0
- package/dist/bin/stuntman.d.ts +1 -0
- package/dist/bin/stuntman.d.ts.map +1 -0
- package/dist/bin/stuntman.js +2 -1
- package/dist/bin/stuntman.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -0
- package/dist/ipUtils.d.ts +1 -0
- package/dist/ipUtils.d.ts.map +1 -0
- package/dist/ipUtils.js +1 -0
- package/dist/ipUtils.js.map +1 -0
- package/dist/mock.d.ts +6 -4
- package/dist/mock.d.ts.map +1 -0
- package/dist/mock.js +122 -143
- package/dist/mock.js.map +1 -0
- package/dist/requestContext.d.ts +1 -0
- package/dist/requestContext.d.ts.map +1 -0
- package/dist/requestContext.js +1 -0
- package/dist/requestContext.js.map +1 -0
- package/dist/ruleExecutor.d.ts +1 -0
- package/dist/ruleExecutor.d.ts.map +1 -0
- package/dist/ruleExecutor.js +2 -0
- package/dist/ruleExecutor.js.map +1 -0
- package/dist/rules/catchAll.d.ts +1 -0
- package/dist/rules/catchAll.d.ts.map +1 -0
- package/dist/rules/catchAll.js +1 -0
- package/dist/rules/catchAll.js.map +1 -0
- package/dist/rules/echo.d.ts +1 -0
- package/dist/rules/echo.d.ts.map +1 -0
- package/dist/rules/echo.js +1 -0
- package/dist/rules/echo.js.map +1 -0
- package/dist/rules/index.d.ts +1 -0
- package/dist/rules/index.d.ts.map +1 -0
- package/dist/rules/index.js +5 -4
- package/dist/rules/index.js.map +1 -0
- package/dist/storage.d.ts +1 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +1 -0
- package/dist/storage.js.map +1 -0
- package/package.json +4 -4
- package/src/api/api.ts +62 -35
- package/src/api/webgui/rules.pug +2 -2
- package/src/api/webgui/style.css +3 -3
- package/src/api/webgui/traffic.pug +1 -0
- package/src/bin/stuntman.ts +2 -2
- package/src/ipUtils.ts +1 -1
- package/src/mock.ts +137 -160
- package/src/ruleExecutor.ts +2 -1
- package/src/rules/index.ts +5 -5
- package/src/storage.ts +2 -2
package/src/api/api.ts
CHANGED
|
@@ -19,67 +19,81 @@ const API_KEY_HEADER = 'x-api-key';
|
|
|
19
19
|
|
|
20
20
|
export class API {
|
|
21
21
|
protected options: Required<ApiOptions>;
|
|
22
|
-
protected
|
|
22
|
+
protected webGuiOptions: Stuntman.WebGuiConfig;
|
|
23
|
+
protected apiApp: ExpressServer | null = null;
|
|
23
24
|
trafficStore: LRUCache<string, Stuntman.LogEntry>;
|
|
24
25
|
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
26
|
|
|
29
|
-
constructor(options: ApiOptions, webGuiOptions
|
|
27
|
+
constructor(options: ApiOptions, webGuiOptions: Stuntman.WebGuiConfig = { disabled: false }) {
|
|
30
28
|
if (!options.apiKeyReadOnly !== !options.apiKeyReadWrite) {
|
|
31
29
|
throw new Error('apiKeyReadOnly and apiKeyReadWrite options need to be set either both or none');
|
|
32
30
|
}
|
|
33
31
|
this.options = options;
|
|
32
|
+
this.webGuiOptions = webGuiOptions;
|
|
34
33
|
|
|
35
34
|
this.trafficStore = getTrafficStore(this.options.mockUuid);
|
|
36
|
-
this.
|
|
37
|
-
|
|
38
|
-
this.
|
|
39
|
-
|
|
35
|
+
this.auth = this.auth.bind(this);
|
|
36
|
+
this.authReadOnly = this.authReadOnly.bind(this);
|
|
37
|
+
this.authReadWrite = this.authReadWrite.bind(this);
|
|
38
|
+
}
|
|
40
39
|
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
}
|
|
40
|
+
private auth(req: Request, type: 'read' | 'write'): void {
|
|
41
|
+
if (!this.options.apiKeyReadOnly && !this.options.apiKeyReadWrite) {
|
|
51
42
|
return;
|
|
52
|
-
}
|
|
43
|
+
}
|
|
44
|
+
const hasValidReadKey = req.header(API_KEY_HEADER) === this.options.apiKeyReadOnly;
|
|
45
|
+
const hasValidWriteKey = req.header(API_KEY_HEADER) === this.options.apiKeyReadWrite;
|
|
46
|
+
const hasValidKey = type === 'read' ? hasValidReadKey || hasValidWriteKey : hasValidWriteKey;
|
|
47
|
+
if (!hasValidKey) {
|
|
48
|
+
throw new AppError({ httpCode: HttpCode.UNAUTHORIZED, message: 'unauthorized' });
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
53
52
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
53
|
+
protected authReadOnly(req: Request, _res: Response, next: NextFunction): void {
|
|
54
|
+
this.auth(req, 'read');
|
|
55
|
+
next();
|
|
56
|
+
}
|
|
58
57
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
protected authReadWrite(req: Request, _res: Response, next: NextFunction): void {
|
|
59
|
+
this.auth(req, 'write');
|
|
60
|
+
next();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private initApi() {
|
|
64
|
+
this.apiApp = express();
|
|
63
65
|
|
|
64
|
-
this.apiApp.use((
|
|
66
|
+
this.apiApp.use(express.json());
|
|
67
|
+
this.apiApp.use(express.text());
|
|
68
|
+
|
|
69
|
+
this.apiApp.use((req: Request, _res: Response, next: NextFunction) => {
|
|
65
70
|
RequestContext.bind(req, this.options.mockUuid);
|
|
66
71
|
next();
|
|
67
72
|
});
|
|
68
73
|
|
|
69
|
-
this.apiApp.get('/rule', this.authReadOnly, async (
|
|
74
|
+
this.apiApp.get('/rule', this.authReadOnly.bind, async (_req, res) => {
|
|
70
75
|
res.send(stringify(await getRuleExecutor(this.options.mockUuid).getRules()));
|
|
71
76
|
});
|
|
72
77
|
|
|
73
78
|
this.apiApp.get('/rule/:ruleId', this.authReadOnly, async (req, res) => {
|
|
79
|
+
if (!req.params.ruleId) {
|
|
80
|
+
throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'missing ruleId' });
|
|
81
|
+
}
|
|
74
82
|
res.send(stringify(await getRuleExecutor(this.options.mockUuid).getRule(req.params.ruleId)));
|
|
75
83
|
});
|
|
76
84
|
|
|
77
85
|
this.apiApp.get('/rule/:ruleId/disable', this.authReadWrite, (req, res) => {
|
|
86
|
+
if (!req.params.ruleId) {
|
|
87
|
+
throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'missing ruleId' });
|
|
88
|
+
}
|
|
78
89
|
getRuleExecutor(this.options.mockUuid).disableRule(req.params.ruleId);
|
|
79
90
|
res.send();
|
|
80
91
|
});
|
|
81
92
|
|
|
82
93
|
this.apiApp.get('/rule/:ruleId/enable', this.authReadWrite, (req, res) => {
|
|
94
|
+
if (!req.params.ruleId) {
|
|
95
|
+
throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'missing ruleId' });
|
|
96
|
+
}
|
|
83
97
|
getRuleExecutor(this.options.mockUuid).enableRule(req.params.ruleId);
|
|
84
98
|
res.send();
|
|
85
99
|
});
|
|
@@ -98,11 +112,14 @@ export class API {
|
|
|
98
112
|
);
|
|
99
113
|
|
|
100
114
|
this.apiApp.get('/rule/:ruleId/remove', this.authReadWrite, async (req, res) => {
|
|
115
|
+
if (!req.params.ruleId) {
|
|
116
|
+
throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'missing ruleId' });
|
|
117
|
+
}
|
|
101
118
|
await getRuleExecutor(this.options.mockUuid).removeRule(req.params.ruleId);
|
|
102
119
|
res.send();
|
|
103
120
|
});
|
|
104
121
|
|
|
105
|
-
this.apiApp.get('/traffic', this.authReadOnly, (
|
|
122
|
+
this.apiApp.get('/traffic', this.authReadOnly, (_req, res) => {
|
|
106
123
|
const serializedTraffic: Stuntman.LogEntry[] = [];
|
|
107
124
|
for (const value of this.trafficStore.values()) {
|
|
108
125
|
serializedTraffic.push(value);
|
|
@@ -111,6 +128,9 @@ export class API {
|
|
|
111
128
|
});
|
|
112
129
|
|
|
113
130
|
this.apiApp.get('/traffic/:ruleIdOrLabel', this.authReadOnly, (req, res) => {
|
|
131
|
+
if (!req.params.ruleIdOrLabel) {
|
|
132
|
+
throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'missing ruleIdOrLabel' });
|
|
133
|
+
}
|
|
114
134
|
const serializedTraffic: Stuntman.LogEntry[] = [];
|
|
115
135
|
for (const value of this.trafficStore.values()) {
|
|
116
136
|
if (value.mockRuleId === req.params.ruleIdOrLabel || (value.labels || []).includes(req.params.ruleIdOrLabel)) {
|
|
@@ -120,13 +140,13 @@ export class API {
|
|
|
120
140
|
res.json(serializedTraffic);
|
|
121
141
|
});
|
|
122
142
|
|
|
123
|
-
if (!webGuiOptions
|
|
143
|
+
if (!this.webGuiOptions.disabled) {
|
|
124
144
|
this.apiApp.set('views', __dirname + '/webgui');
|
|
125
145
|
this.apiApp.set('view engine', 'pug');
|
|
126
146
|
this.initWebGui();
|
|
127
147
|
}
|
|
128
148
|
|
|
129
|
-
this.apiApp.all(/.*/, (
|
|
149
|
+
this.apiApp.all(/.*/, (_req: Request, res: Response) => res.status(404).send());
|
|
130
150
|
|
|
131
151
|
this.apiApp.use((error: Error | AppError, req: Request, res: Response) => {
|
|
132
152
|
const ctx: RequestContext | null = RequestContext.get(req);
|
|
@@ -152,7 +172,10 @@ export class API {
|
|
|
152
172
|
}
|
|
153
173
|
|
|
154
174
|
private initWebGui() {
|
|
155
|
-
this.apiApp
|
|
175
|
+
if (!this.apiApp) {
|
|
176
|
+
throw new Error('initialization error');
|
|
177
|
+
}
|
|
178
|
+
this.apiApp.get('/webgui/rules', this.authReadOnly, async (_req, res) => {
|
|
156
179
|
const rules: Record<string, string> = {};
|
|
157
180
|
for (const rule of await getRuleExecutor(this.options.mockUuid).getRules()) {
|
|
158
181
|
rules[rule.id] = serializeJavascript(liveRuleToRule(rule), { unsafe: true });
|
|
@@ -160,7 +183,7 @@ export class API {
|
|
|
160
183
|
res.render('rules', { rules: escapedSerialize(rules), INDEX_DTS, ruleKeys: Object.keys(rules) });
|
|
161
184
|
});
|
|
162
185
|
|
|
163
|
-
this.apiApp.get('/webgui/traffic', this.authReadOnly, async (
|
|
186
|
+
this.apiApp.get('/webgui/traffic', this.authReadOnly, async (_req, res) => {
|
|
164
187
|
const serializedTraffic: Stuntman.LogEntry[] = [];
|
|
165
188
|
for (const value of this.trafficStore.values()) {
|
|
166
189
|
serializedTraffic.push(value);
|
|
@@ -208,6 +231,10 @@ export class API {
|
|
|
208
231
|
if (this.server) {
|
|
209
232
|
throw new Error('mock server already started');
|
|
210
233
|
}
|
|
234
|
+
this.initApi();
|
|
235
|
+
if (!this.apiApp) {
|
|
236
|
+
throw new Error('initialization error');
|
|
237
|
+
}
|
|
211
238
|
this.server = this.apiApp.listen(this.options.port, () => {
|
|
212
239
|
logger.info(`API listening on ${this.options.port}`);
|
|
213
240
|
});
|
package/src/api/webgui/rules.pug
CHANGED
|
@@ -22,6 +22,7 @@ html
|
|
|
22
22
|
div(style='margin-left: 240px')
|
|
23
23
|
#container(style='height: 400px')
|
|
24
24
|
script.
|
|
25
|
+
/* eslint no-undef: 0 */
|
|
25
26
|
const uuidv4 = () => {
|
|
26
27
|
function getRandomSymbol(symbol) {
|
|
27
28
|
var array;
|
|
@@ -72,7 +73,6 @@ html
|
|
|
72
73
|
}
|
|
73
74
|
const editor = monaco.editor.create(document.getElementById('container'), {
|
|
74
75
|
theme: 'vs-dark',
|
|
75
|
-
autoIndent: true,
|
|
76
76
|
formatOnPaste: true,
|
|
77
77
|
formatOnType: true,
|
|
78
78
|
automaticLayout: true,
|
|
@@ -135,7 +135,7 @@ html
|
|
|
135
135
|
|
|
136
136
|
window.newRule = () => {
|
|
137
137
|
const ruleId = uuidv4();
|
|
138
|
-
const emptyRule = `import type * as Stuntman from
|
|
138
|
+
const emptyRule = `import type * as Stuntman from 'stuntman';\n\nvar STUNTMAN_RULE: Stuntman.Rule = { id: '${ruleId}', matches: (req: Stuntman.Request) => true, ttlSeconds: 600, actions: { mockResponse: { status: '200', body: '${ruleId}' }} };`;
|
|
139
139
|
models[ruleId] = monaco.editor.createModel(emptyRule, 'typescript', `file:///${ruleId}.ts`);
|
|
140
140
|
const ruleKeyNode = document.getElementById('ruleKeys').firstChild;
|
|
141
141
|
const ruleKeyNodeClone = ruleKeyNode.cloneNode(true);
|
package/src/api/webgui/style.css
CHANGED
package/src/bin/stuntman.ts
CHANGED
package/src/ipUtils.ts
CHANGED
|
@@ -55,7 +55,7 @@ export class IPUtils {
|
|
|
55
55
|
return;
|
|
56
56
|
}
|
|
57
57
|
logger.debug({ ip: addresses, hostname }, 'resolved hostname');
|
|
58
|
-
resolve([addresses[0]
|
|
58
|
+
resolve([addresses[0]!, ...addresses.slice(1)]);
|
|
59
59
|
};
|
|
60
60
|
if (options?.useExternalDns) {
|
|
61
61
|
if (!this.externalDns) {
|
package/src/mock.ts
CHANGED
|
@@ -6,7 +6,7 @@ import express from 'express';
|
|
|
6
6
|
import { v4 as uuidv4 } from 'uuid';
|
|
7
7
|
import { getRuleExecutor } from './ruleExecutor';
|
|
8
8
|
import { getTrafficStore } from './storage';
|
|
9
|
-
import { RawHeaders, logger, HttpCode } from '@stuntman/shared';
|
|
9
|
+
import { RawHeaders, logger, HttpCode, naiveGQLParser, escapeStringRegexp } from '@stuntman/shared';
|
|
10
10
|
import RequestContext from './requestContext';
|
|
11
11
|
import type * as Stuntman from '@stuntman/shared';
|
|
12
12
|
import { IPUtils } from './ipUtils';
|
|
@@ -17,42 +17,12 @@ type WithRequiredProperty<Type, Key extends keyof Type> = Type & {
|
|
|
17
17
|
[Property in Key]-?: Type[Property];
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
-
const naiveGQLParser = (body: Buffer | string): Stuntman.GQLRequestBody | undefined => {
|
|
21
|
-
try {
|
|
22
|
-
let json: Stuntman.GQLRequestBody | undefined = undefined;
|
|
23
|
-
try {
|
|
24
|
-
json = JSON.parse(Buffer.isBuffer(body) ? body.toString('utf-8') : body);
|
|
25
|
-
} catch (kiss) {
|
|
26
|
-
// and swallow
|
|
27
|
-
}
|
|
28
|
-
if (!json?.query && !json?.operationName) {
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
const lines = json.query
|
|
32
|
-
.split('\n')
|
|
33
|
-
.map((l) => l.replace(/^\s+/g, '').trim())
|
|
34
|
-
.filter((l) => !!l);
|
|
35
|
-
if (/^query /.test(lines[0])) {
|
|
36
|
-
json.type = 'query';
|
|
37
|
-
} else if (/^mutation /.test(lines[0])) {
|
|
38
|
-
json.type = 'mutation';
|
|
39
|
-
} else {
|
|
40
|
-
throw new Error(`Unable to resolve query type of ${lines[0]}`);
|
|
41
|
-
}
|
|
42
|
-
json.methodName = lines[json.operationName ? 1 : 0].split('(')[0].split('{')[0];
|
|
43
|
-
return json;
|
|
44
|
-
} catch (error) {
|
|
45
|
-
logger.debug(error, 'unable to parse GQL');
|
|
46
|
-
}
|
|
47
|
-
return undefined;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
20
|
// TODO add proper web proxy mode
|
|
51
21
|
|
|
52
22
|
export class Mock {
|
|
53
23
|
public readonly mockUuid: string;
|
|
54
|
-
protected options: Stuntman.
|
|
55
|
-
protected mockApp: express.Express;
|
|
24
|
+
protected options: Stuntman.Config;
|
|
25
|
+
protected mockApp: express.Express | null = null;
|
|
56
26
|
protected MOCK_DOMAIN_REGEX: RegExp;
|
|
57
27
|
protected URL_PORT_REGEX: RegExp;
|
|
58
28
|
protected server: http.Server | null = null;
|
|
@@ -60,7 +30,6 @@ export class Mock {
|
|
|
60
30
|
protected trafficStore: LRUCache<string, Stuntman.LogEntry>;
|
|
61
31
|
protected ipUtils: IPUtils | null = null;
|
|
62
32
|
private _api: API | null = null;
|
|
63
|
-
private requestHandler: (req: express.Request, res: express.Response) => Promise<void>;
|
|
64
33
|
|
|
65
34
|
get apiServer() {
|
|
66
35
|
if (this.options.api.disabled) {
|
|
@@ -76,7 +45,7 @@ export class Mock {
|
|
|
76
45
|
return getRuleExecutor(this.mockUuid);
|
|
77
46
|
}
|
|
78
47
|
|
|
79
|
-
constructor(options: Stuntman.
|
|
48
|
+
constructor(options: Stuntman.Config) {
|
|
80
49
|
this.mockUuid = uuidv4();
|
|
81
50
|
this.options = options;
|
|
82
51
|
if (this.options.mock.httpsPort && (!this.options.mock.httpsKey || !this.options.mock.httpsCert)) {
|
|
@@ -101,147 +70,151 @@ export class Mock {
|
|
|
101
70
|
? null
|
|
102
71
|
: new IPUtils({ mockUuid: this.mockUuid, externalDns: this.options.mock.externalDns });
|
|
103
72
|
|
|
104
|
-
this.requestHandler =
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
73
|
+
this.requestHandler = this.requestHandler.bind(this);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private async requestHandler(req: express.Request, res: express.Response): Promise<void> {
|
|
77
|
+
const ctx: RequestContext | null = RequestContext.get(req);
|
|
78
|
+
const requestUuid = ctx?.uuid || uuidv4();
|
|
79
|
+
const timestamp = Date.now();
|
|
80
|
+
const originalHostname = req.headers.host || req.hostname;
|
|
81
|
+
const unproxiedHostname = req.hostname.replace(this.MOCK_DOMAIN_REGEX, '');
|
|
82
|
+
const isProxiedHostname = originalHostname !== unproxiedHostname;
|
|
83
|
+
const originalRequest = {
|
|
84
|
+
id: requestUuid,
|
|
85
|
+
timestamp,
|
|
86
|
+
url: `${req.protocol}://${req.hostname}${req.originalUrl}`,
|
|
87
|
+
method: req.method,
|
|
88
|
+
rawHeaders: new RawHeaders(...req.rawHeaders),
|
|
89
|
+
...((Buffer.isBuffer(req.body) && { body: req.body.toString('utf-8') }) ||
|
|
90
|
+
(typeof req.body === 'string' && { body: req.body })),
|
|
91
|
+
};
|
|
92
|
+
logger.debug(originalRequest, 'processing request');
|
|
93
|
+
const logContext: Record<string, any> = {
|
|
94
|
+
requestId: originalRequest.id,
|
|
95
|
+
};
|
|
96
|
+
const mockEntry: WithRequiredProperty<Stuntman.LogEntry, 'modifiedRequest'> = {
|
|
97
|
+
originalRequest,
|
|
98
|
+
modifiedRequest: {
|
|
99
|
+
...this.unproxyRequest(req),
|
|
112
100
|
id: requestUuid,
|
|
113
101
|
timestamp,
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (!isProxiedHostname) {
|
|
134
|
-
this.removeProxyPort(mockEntry.modifiedRequest);
|
|
135
|
-
}
|
|
136
|
-
const matchingRule = await getRuleExecutor(this.mockUuid).findMatchingRule(mockEntry.modifiedRequest);
|
|
137
|
-
if (matchingRule) {
|
|
138
|
-
mockEntry.mockRuleId = matchingRule.id;
|
|
139
|
-
mockEntry.labels = matchingRule.labels;
|
|
140
|
-
if (matchingRule.actions.mockResponse) {
|
|
141
|
-
const staticResponse =
|
|
142
|
-
typeof matchingRule.actions.mockResponse === 'function'
|
|
143
|
-
? matchingRule.actions.mockResponse(mockEntry.modifiedRequest)
|
|
144
|
-
: matchingRule.actions.mockResponse;
|
|
145
|
-
mockEntry.modifiedResponse = staticResponse;
|
|
146
|
-
logger.debug({ ...logContext, staticResponse }, 'replying with mocked response');
|
|
147
|
-
if (matchingRule.storeTraffic) {
|
|
148
|
-
this.trafficStore.set(requestUuid, mockEntry);
|
|
149
|
-
}
|
|
150
|
-
if (staticResponse.rawHeaders) {
|
|
151
|
-
for (const header of staticResponse.rawHeaders.toHeaderPairs()) {
|
|
152
|
-
res.setHeader(header[0], header[1]);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
res.status(staticResponse.status || 200);
|
|
156
|
-
res.send(staticResponse.body);
|
|
157
|
-
// static response blocks any further processing
|
|
158
|
-
return;
|
|
102
|
+
...(originalRequest.body && { gqlBody: naiveGQLParser(originalRequest.body) }),
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
if (!isProxiedHostname) {
|
|
106
|
+
this.removeProxyPort(mockEntry.modifiedRequest);
|
|
107
|
+
}
|
|
108
|
+
const matchingRule = await getRuleExecutor(this.mockUuid).findMatchingRule(mockEntry.modifiedRequest);
|
|
109
|
+
if (matchingRule) {
|
|
110
|
+
mockEntry.mockRuleId = matchingRule.id;
|
|
111
|
+
mockEntry.labels = matchingRule.labels;
|
|
112
|
+
if (matchingRule.actions.mockResponse) {
|
|
113
|
+
const staticResponse =
|
|
114
|
+
typeof matchingRule.actions.mockResponse === 'function'
|
|
115
|
+
? matchingRule.actions.mockResponse(mockEntry.modifiedRequest)
|
|
116
|
+
: matchingRule.actions.mockResponse;
|
|
117
|
+
mockEntry.modifiedResponse = staticResponse;
|
|
118
|
+
logger.debug({ ...logContext, staticResponse }, 'replying with mocked response');
|
|
119
|
+
if (matchingRule.storeTraffic) {
|
|
120
|
+
this.trafficStore.set(requestUuid, mockEntry);
|
|
159
121
|
}
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
-
|
|
122
|
+
if (staticResponse.rawHeaders) {
|
|
123
|
+
for (const header of staticResponse.rawHeaders.toHeaderPairs()) {
|
|
124
|
+
res.setHeader(header[0], header[1]);
|
|
125
|
+
}
|
|
163
126
|
}
|
|
127
|
+
res.status(staticResponse.status || 200);
|
|
128
|
+
res.send(staticResponse.body);
|
|
129
|
+
// static response blocks any further processing
|
|
130
|
+
return;
|
|
164
131
|
}
|
|
165
|
-
if (
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
132
|
+
if (matchingRule.actions.modifyRequest) {
|
|
133
|
+
mockEntry.modifiedRequest = matchingRule.actions.modifyRequest(mockEntry.modifiedRequest);
|
|
134
|
+
logger.debug({ ...logContext, modifiedRequest: mockEntry.modifiedRequest }, 'modified original request');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (this.ipUtils && !isProxiedHostname && !this.ipUtils.isIP(originalHostname)) {
|
|
138
|
+
const hostname = originalHostname.split(':')[0]!;
|
|
139
|
+
try {
|
|
140
|
+
const internalIPs = await this.ipUtils.resolveIP(hostname);
|
|
141
|
+
if (this.ipUtils.isLocalhostIP(internalIPs) && this.options.mock.externalDns.length) {
|
|
142
|
+
const externalIPs = await this.ipUtils.resolveIP(hostname, { useExternalDns: true });
|
|
143
|
+
logger.debug({ ...logContext, hostname, externalIPs, internalIPs }, 'switched to external IP');
|
|
144
|
+
mockEntry.modifiedRequest.url = mockEntry.modifiedRequest.url.replace(
|
|
145
|
+
/^(https?:\/\/)[^:/]+/i,
|
|
146
|
+
`$1${externalIPs}`
|
|
147
|
+
);
|
|
180
148
|
}
|
|
149
|
+
} catch (error) {
|
|
150
|
+
// swallow the exeception, don't think much can be done at this point
|
|
151
|
+
logger.warn({ ...logContext, error }, `error trying to resolve IP for "${hostname}"`);
|
|
181
152
|
}
|
|
153
|
+
}
|
|
182
154
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
155
|
+
const originalResponse: Required<Stuntman.Response> = this.options.mock.disableProxy
|
|
156
|
+
? {
|
|
157
|
+
timestamp: Date.now(),
|
|
158
|
+
body: undefined,
|
|
159
|
+
rawHeaders: new RawHeaders(),
|
|
160
|
+
status: 404,
|
|
161
|
+
}
|
|
162
|
+
: await this.proxyRequest(req, mockEntry, logContext);
|
|
191
163
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
164
|
+
logger.debug({ ...logContext, originalResponse }, 'received response');
|
|
165
|
+
mockEntry.originalResponse = originalResponse;
|
|
166
|
+
let modifedResponse: Stuntman.Response = {
|
|
167
|
+
...originalResponse,
|
|
168
|
+
rawHeaders: new RawHeaders(
|
|
169
|
+
...Array.from(originalResponse.rawHeaders.toHeaderPairs()).flatMap(([key, value]) => {
|
|
170
|
+
// 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)
|
|
171
|
+
return [
|
|
172
|
+
key,
|
|
173
|
+
isProxiedHostname
|
|
174
|
+
? value
|
|
175
|
+
: value.replace(
|
|
176
|
+
new RegExp(`(?:^|\\b)(${escapeStringRegexp(unproxiedHostname)})(?:\\b|$)`, 'igm'),
|
|
177
|
+
originalHostname
|
|
178
|
+
),
|
|
179
|
+
];
|
|
180
|
+
})
|
|
181
|
+
),
|
|
182
|
+
};
|
|
183
|
+
if (matchingRule?.actions.modifyResponse) {
|
|
184
|
+
modifedResponse = matchingRule?.actions.modifyResponse(mockEntry.modifiedRequest, originalResponse);
|
|
185
|
+
logger.debug({ ...logContext, modifedResponse }, 'modified response');
|
|
186
|
+
}
|
|
215
187
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
188
|
+
mockEntry.modifiedResponse = modifedResponse;
|
|
189
|
+
if (matchingRule?.storeTraffic) {
|
|
190
|
+
this.trafficStore.set(requestUuid, mockEntry);
|
|
191
|
+
}
|
|
220
192
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
header[0],
|
|
233
|
-
isProxiedHostname ? header[1].replace(unproxiedHostname, originalHostname) : header[1]
|
|
234
|
-
);
|
|
235
|
-
}
|
|
193
|
+
if (modifedResponse.status) {
|
|
194
|
+
res.status(modifedResponse.status);
|
|
195
|
+
}
|
|
196
|
+
if (modifedResponse.rawHeaders) {
|
|
197
|
+
for (const header of modifedResponse.rawHeaders.toHeaderPairs()) {
|
|
198
|
+
// since fetch decompresses responses we need to get rid of some headers
|
|
199
|
+
// TODO maybe could be handled better than just skipping, although express should add these back for new body
|
|
200
|
+
// if (/^content-(?:length|encoding)$/i.test(header[0])) {
|
|
201
|
+
// continue;
|
|
202
|
+
// }
|
|
203
|
+
res.setHeader(header[0], isProxiedHostname ? header[1].replace(unproxiedHostname, originalHostname) : header[1]);
|
|
236
204
|
}
|
|
237
|
-
|
|
238
|
-
|
|
205
|
+
}
|
|
206
|
+
res.end(Buffer.from(modifedResponse.body, 'binary'));
|
|
207
|
+
}
|
|
239
208
|
|
|
209
|
+
init() {
|
|
210
|
+
if (this.mockApp) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
240
213
|
this.mockApp = express();
|
|
241
214
|
// TODO for now request body is just a buffer passed further, not inflated
|
|
242
215
|
this.mockApp.use(express.raw({ type: '*/*' }));
|
|
243
216
|
|
|
244
|
-
this.mockApp.use((req: express.Request,
|
|
217
|
+
this.mockApp.use((req: express.Request, _res: express.Response, next: express.NextFunction) => {
|
|
245
218
|
RequestContext.bind(req, this.mockUuid);
|
|
246
219
|
next();
|
|
247
220
|
});
|
|
@@ -315,6 +288,10 @@ export class Mock {
|
|
|
315
288
|
}
|
|
316
289
|
|
|
317
290
|
public start() {
|
|
291
|
+
this.init();
|
|
292
|
+
if (!this.mockApp) {
|
|
293
|
+
throw new Error('initialization error');
|
|
294
|
+
}
|
|
318
295
|
if (this.server) {
|
|
319
296
|
throw new Error('mock server already started');
|
|
320
297
|
}
|
|
@@ -376,7 +353,7 @@ export class Mock {
|
|
|
376
353
|
host.endsWith(`:${this.options.mock.port}`) ||
|
|
377
354
|
(this.options.mock.httpsPort && host.endsWith(`:${this.options.mock.httpsPort}`))
|
|
378
355
|
) {
|
|
379
|
-
req.rawHeaders.set('host', host.split(':')[0]);
|
|
356
|
+
req.rawHeaders.set('host', host.split(':')[0]!);
|
|
380
357
|
}
|
|
381
358
|
}
|
|
382
359
|
}
|
package/src/ruleExecutor.ts
CHANGED
|
@@ -136,6 +136,7 @@ class RuleExecutor implements Stuntman.RuleExecutorInterface {
|
|
|
136
136
|
} catch (error) {
|
|
137
137
|
logger.error({ ...logContext, ruleId: rule?.id, error }, 'error in rule match function');
|
|
138
138
|
}
|
|
139
|
+
return undefined;
|
|
139
140
|
});
|
|
140
141
|
if (!matchingRule) {
|
|
141
142
|
logger.debug(logContext, 'no matching rule found');
|
|
@@ -207,5 +208,5 @@ export const getRuleExecutor = (mockUuid: string): RuleExecutor => {
|
|
|
207
208
|
[...DEFAULT_RULES, ...CUSTOM_RULES].map((r) => ({ ...r, ttlSeconds: Infinity }))
|
|
208
209
|
);
|
|
209
210
|
}
|
|
210
|
-
return ruleExecutors[mockUuid]
|
|
211
|
+
return ruleExecutors[mockUuid]!;
|
|
211
212
|
};
|