@stuntman/server 0.1.1 → 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 +9 -2
- package/dist/api/api.d.ts +4 -1
- package/dist/api/api.js +51 -28
- package/dist/api/utils.js +2 -2
- package/dist/api/{validatiors.js → validators.js} +1 -1
- package/dist/mock.d.ts +4 -1
- package/dist/mock.js +166 -153
- package/dist/ruleExecutor.d.ts +3 -2
- package/dist/ruleExecutor.js +30 -15
- package/dist/rules/index.d.ts +1 -0
- package/dist/rules/index.js +65 -2
- package/package.json +12 -5
- /package/dist/api/{validatiors.d.ts → validators.d.ts} +0 -0
package/README.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Stuntman
|
|
2
2
|
|
|
3
|
+
Stuntman is a proxy/mock server that can be deployed remotely together with your application under test, working as either pass-through proxy allowing you to inspect traffic or proxy/mock which can intercept requests/responses and modify them or stub with predefined ones.
|
|
4
|
+
|
|
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
|
+
|
|
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
|
+
|
|
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
|
+
|
|
3
11
|
## Building from source
|
|
4
12
|
|
|
5
13
|
### Prerequisites
|
|
@@ -22,6 +30,7 @@ pnpm stuntman
|
|
|
22
30
|
## Configuration
|
|
23
31
|
|
|
24
32
|
Stuntman uses [config](https://github.com/node-config/node-config)
|
|
33
|
+
|
|
25
34
|
You can create `config/default.json` with settings of your liking matching `ServerConfig` type
|
|
26
35
|
|
|
27
36
|
## Running as a package
|
|
@@ -68,8 +77,6 @@ for local playground you can also use `http://www.example.com.localhost:2015`
|
|
|
68
77
|
|
|
69
78
|
### Take a look at client
|
|
70
79
|
|
|
71
|
-
Take a look at `./src/clientTestExample.ts`, you can use it to set up some rules
|
|
72
|
-
|
|
73
80
|
Mind the scope of `Stuntman.RemotableFunction` like `matches`, `modifyRequest`, `modifyResponse`.
|
|
74
81
|
`Stuntman.RemotableFunction.localFn` contains the function, but since it'll be executed on a remote mock server it cannot access any variables outside it's body. In order to pass variable values into the function use `Stuntman.RemotableFunction.variables` for example:
|
|
75
82
|
|
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) => {
|
|
29
|
-
res.send((0, shared_1.stringify)(await ruleExecutor_1.
|
|
49
|
+
this.apiApp.get('/rule', this.authReadOnly, async (req, res) => {
|
|
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) => {
|
|
32
|
-
res.send((0, shared_1.stringify)(await ruleExecutor_1.
|
|
52
|
+
this.apiApp.get('/rule/:ruleId', this.authReadOnly, async (req, res) => {
|
|
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) => {
|
|
35
|
-
ruleExecutor_1.
|
|
55
|
+
this.apiApp.get('/rule/:ruleId/disable', this.authReadWrite, (req, res) => {
|
|
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) => {
|
|
39
|
-
ruleExecutor_1.
|
|
59
|
+
this.apiApp.get('/rule/:ruleId/enable', this.authReadWrite, (req, res) => {
|
|
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
|
-
const rule = await ruleExecutor_1.
|
|
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) => {
|
|
51
|
-
await ruleExecutor_1.
|
|
71
|
+
this.apiApp.get('/rule/:ruleId/remove', this.authReadWrite, async (req, res) => {
|
|
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) {
|
|
@@ -84,24 +111,20 @@ class API {
|
|
|
84
111
|
});
|
|
85
112
|
return;
|
|
86
113
|
}
|
|
87
|
-
|
|
114
|
+
// eslint-disable-next-line no-console
|
|
115
|
+
console.log('API server encountered a critical error. Exiting');
|
|
88
116
|
process.exit(1);
|
|
89
117
|
});
|
|
90
|
-
this.apiApp.set('views', __dirname + '/webgui');
|
|
91
|
-
this.apiApp.set('view engine', 'pug');
|
|
92
|
-
if (!(webGuiOptions === null || webGuiOptions === void 0 ? void 0 : webGuiOptions.disabled)) {
|
|
93
|
-
this.initWebGui();
|
|
94
|
-
}
|
|
95
118
|
}
|
|
96
119
|
initWebGui() {
|
|
97
|
-
this.apiApp.get('/webgui/rules', async (req, res) => {
|
|
120
|
+
this.apiApp.get('/webgui/rules', this.authReadOnly, async (req, res) => {
|
|
98
121
|
const rules = {};
|
|
99
|
-
for (const rule of await ruleExecutor_1.
|
|
122
|
+
for (const rule of await (0, ruleExecutor_1.getRuleExecutor)(this.options.mockUuid).getRules()) {
|
|
100
123
|
rules[rule.id] = (0, serialize_javascript_1.default)((0, utils_1.liveRuleToRule)(rule), { unsafe: true });
|
|
101
124
|
}
|
|
102
125
|
res.render('rules', { rules: (0, utils_1.escapedSerialize)(rules), INDEX_DTS: shared_1.INDEX_DTS, ruleKeys: Object.keys(rules) });
|
|
103
126
|
});
|
|
104
|
-
this.apiApp.get('/webgui/traffic', async (req, res) => {
|
|
127
|
+
this.apiApp.get('/webgui/traffic', this.authReadOnly, async (req, res) => {
|
|
105
128
|
const serializedTraffic = [];
|
|
106
129
|
for (const value of this.trafficStore.values()) {
|
|
107
130
|
serializedTraffic.push(value);
|
|
@@ -111,7 +134,7 @@ class API {
|
|
|
111
134
|
});
|
|
112
135
|
});
|
|
113
136
|
// TODO make webui way better and safer, nicer formatting, eslint/prettier, blackjack and hookers
|
|
114
|
-
this.apiApp.post('/webgui/rules/unsafesave', async (req, res) => {
|
|
137
|
+
this.apiApp.post('/webgui/rules/unsafesave', this.authReadWrite, async (req, res) => {
|
|
115
138
|
const rule = new Function(req.body)();
|
|
116
139
|
if (!rule ||
|
|
117
140
|
!rule.id ||
|
|
@@ -120,7 +143,7 @@ class API {
|
|
|
120
143
|
rule.ttlSeconds > shared_1.MAX_RULE_TTL_SECONDS) {
|
|
121
144
|
throw new shared_1.AppError({ httpCode: shared_1.HttpCode.BAD_REQUEST, message: 'Invalid rule' });
|
|
122
145
|
}
|
|
123
|
-
await ruleExecutor_1.
|
|
146
|
+
await (0, ruleExecutor_1.getRuleExecutor)(this.options.mockUuid).addRule({
|
|
124
147
|
id: rule.id,
|
|
125
148
|
matches: rule.matches,
|
|
126
149
|
ttlSeconds: rule.ttlSeconds,
|
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),
|
|
@@ -104,7 +104,7 @@ const validateDeserializedRule = (deserializedRule) => {
|
|
|
104
104
|
shared_1.logger.error({ ruleId: deserializedRule.id }, error);
|
|
105
105
|
throw new shared_1.AppError({
|
|
106
106
|
httpCode: shared_1.HttpCode.UNPROCESSABLE_ENTITY,
|
|
107
|
-
message: 'match function
|
|
107
|
+
message: 'match function threw an error',
|
|
108
108
|
});
|
|
109
109
|
}
|
|
110
110
|
if (matchValidationResult !== true &&
|
package/dist/mock.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { IPUtils } from './ipUtils';
|
|
|
8
8
|
import LRUCache from 'lru-cache';
|
|
9
9
|
import { API } from './api/api';
|
|
10
10
|
export declare class Mock {
|
|
11
|
-
|
|
11
|
+
readonly mockUuid: string;
|
|
12
12
|
protected options: Stuntman.ServerConfig;
|
|
13
13
|
protected mockApp: express.Express;
|
|
14
14
|
protected MOCK_DOMAIN_REGEX: RegExp;
|
|
@@ -19,7 +19,10 @@ export declare class Mock {
|
|
|
19
19
|
protected ipUtils: IPUtils | null;
|
|
20
20
|
private _api;
|
|
21
21
|
get apiServer(): API | null;
|
|
22
|
+
get ruleExecutor(): Stuntman.RuleExecutorInterface;
|
|
22
23
|
constructor(options: Stuntman.ServerConfig);
|
|
24
|
+
private proxyRequest;
|
|
25
|
+
private requestHandler;
|
|
23
26
|
start(): void;
|
|
24
27
|
stop(): void;
|
|
25
28
|
protected unproxyRequest(req: express.Request): Stuntman.BaseRequest;
|
package/dist/mock.js
CHANGED
|
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.Mock = void 0;
|
|
7
|
+
const undici_1 = require("undici");
|
|
7
8
|
const https_1 = __importDefault(require("https"));
|
|
8
9
|
const express_1 = __importDefault(require("express"));
|
|
9
10
|
const uuid_1 = require("uuid");
|
|
@@ -57,6 +58,9 @@ class Mock {
|
|
|
57
58
|
}
|
|
58
59
|
return this._api;
|
|
59
60
|
}
|
|
61
|
+
get ruleExecutor() {
|
|
62
|
+
return (0, ruleExecutor_1.getRuleExecutor)(this.mockUuid);
|
|
63
|
+
}
|
|
60
64
|
constructor(options) {
|
|
61
65
|
this.server = null;
|
|
62
66
|
this.serverHttps = null;
|
|
@@ -67,7 +71,7 @@ class Mock {
|
|
|
67
71
|
if (this.options.mock.httpsPort && (!this.options.mock.httpsKey || !this.options.mock.httpsCert)) {
|
|
68
72
|
throw new Error('missing https key/cert');
|
|
69
73
|
}
|
|
70
|
-
this.MOCK_DOMAIN_REGEX = new RegExp(`(?:\\.([0-9]+))?\\.(?:(?:${this.options.mock.domain})|(?:localhost))(
|
|
74
|
+
this.MOCK_DOMAIN_REGEX = new RegExp(`(?:\\.([0-9]+))?\\.(?:(?:${this.options.mock.domain}(https?)?)|(?:localhost))(:${this.options.mock.port}${this.options.mock.httpsPort ? `|${this.options.mock.httpsPort}` : ''})?(?:\\b|$)`, 'i');
|
|
71
75
|
this.URL_PORT_REGEX = new RegExp(`^(https?:\\/\\/[^:/]+):(?:${this.options.mock.port}${this.options.mock.httpsPort ? `|${this.options.mock.httpsPort}` : ''})(\\/.*)`, 'i');
|
|
72
76
|
this.trafficStore = (0, storage_1.getTrafficStore)(this.mockUuid, this.options.storage.traffic);
|
|
73
77
|
this.ipUtils =
|
|
@@ -81,157 +85,7 @@ class Mock {
|
|
|
81
85
|
requestContext_1.default.bind(req, this.mockUuid);
|
|
82
86
|
next();
|
|
83
87
|
});
|
|
84
|
-
this.mockApp.all(/.*/,
|
|
85
|
-
var _a, _b, _c, _d, _e;
|
|
86
|
-
const ctx = requestContext_1.default.get(req);
|
|
87
|
-
const requestUuid = (ctx === null || ctx === void 0 ? void 0 : ctx.uuid) || (0, uuid_1.v4)();
|
|
88
|
-
const timestamp = Date.now();
|
|
89
|
-
const originalHostname = req.hostname;
|
|
90
|
-
const unproxiedHostname = req.hostname.replace(this.MOCK_DOMAIN_REGEX, '');
|
|
91
|
-
const isProxiedHostname = originalHostname !== unproxiedHostname;
|
|
92
|
-
const originalRequest = {
|
|
93
|
-
id: requestUuid,
|
|
94
|
-
timestamp,
|
|
95
|
-
url: `${req.protocol}://${req.hostname}${req.originalUrl}`,
|
|
96
|
-
method: req.method,
|
|
97
|
-
rawHeaders: new shared_1.RawHeaders(...req.rawHeaders),
|
|
98
|
-
...(Buffer.isBuffer(req.body) && { body: req.body.toString('utf-8') }),
|
|
99
|
-
};
|
|
100
|
-
shared_1.logger.debug(originalRequest, 'processing request');
|
|
101
|
-
const logContext = {
|
|
102
|
-
requestId: originalRequest.id,
|
|
103
|
-
};
|
|
104
|
-
const mockEntry = {
|
|
105
|
-
originalRequest,
|
|
106
|
-
modifiedRequest: {
|
|
107
|
-
...this.unproxyRequest(req),
|
|
108
|
-
id: requestUuid,
|
|
109
|
-
timestamp,
|
|
110
|
-
...(originalRequest.body && { gqlBody: naiveGQLParser(originalRequest.body) }),
|
|
111
|
-
},
|
|
112
|
-
};
|
|
113
|
-
if (!isProxiedHostname) {
|
|
114
|
-
this.removeProxyPort(mockEntry.modifiedRequest);
|
|
115
|
-
}
|
|
116
|
-
const matchingRule = await ruleExecutor_1.ruleExecutor.findMatchingRule(mockEntry.modifiedRequest);
|
|
117
|
-
if (matchingRule) {
|
|
118
|
-
mockEntry.mockRuleId = matchingRule.id;
|
|
119
|
-
mockEntry.labels = matchingRule.labels;
|
|
120
|
-
if ((_a = matchingRule.actions) === null || _a === void 0 ? void 0 : _a.mockResponse) {
|
|
121
|
-
const staticResponse = typeof matchingRule.actions.mockResponse === 'function'
|
|
122
|
-
? matchingRule.actions.mockResponse(mockEntry.modifiedRequest)
|
|
123
|
-
: matchingRule.actions.mockResponse;
|
|
124
|
-
mockEntry.modifiedResponse = staticResponse;
|
|
125
|
-
shared_1.logger.debug({ ...logContext, staticResponse }, 'replying with mocked response');
|
|
126
|
-
if (matchingRule.storeTraffic) {
|
|
127
|
-
this.trafficStore.set(requestUuid, mockEntry);
|
|
128
|
-
}
|
|
129
|
-
if (staticResponse.rawHeaders) {
|
|
130
|
-
for (const header of staticResponse.rawHeaders.toHeaderPairs()) {
|
|
131
|
-
res.setHeader(header[0], header[1]);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
res.status(staticResponse.status || 200);
|
|
135
|
-
res.send(staticResponse.body);
|
|
136
|
-
// static response blocks any further processing
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
if ((_b = matchingRule.actions) === null || _b === void 0 ? void 0 : _b.modifyRequest) {
|
|
140
|
-
mockEntry.modifiedRequest = (_c = matchingRule.actions) === null || _c === void 0 ? void 0 : _c.modifyRequest(mockEntry.modifiedRequest);
|
|
141
|
-
shared_1.logger.debug({ ...logContext, modifiedRequest: mockEntry.modifiedRequest }, 'modified original request');
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
if (this.ipUtils && !isProxiedHostname && !this.ipUtils.isIP(originalHostname)) {
|
|
145
|
-
const hostname = originalHostname.split(':')[0];
|
|
146
|
-
try {
|
|
147
|
-
const internalIPs = await this.ipUtils.resolveIP(hostname);
|
|
148
|
-
if (this.ipUtils.isLocalhostIP(internalIPs) && this.options.mock.externalDns.length) {
|
|
149
|
-
const externalIPs = await this.ipUtils.resolveIP(hostname, { useExternalDns: true });
|
|
150
|
-
shared_1.logger.debug({ ...logContext, hostname, externalIPs, internalIPs }, 'switched to external IP');
|
|
151
|
-
mockEntry.modifiedRequest.url = mockEntry.modifiedRequest.url.replace(/^(https?:\/\/)[^:/]+/i, `$1${externalIPs}`);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
catch (error) {
|
|
155
|
-
// swallow the exeception, don't think much can be done at this point
|
|
156
|
-
shared_1.logger.warn({ ...logContext, error }, `error trying to resolve IP for "${hostname}"`);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
let controller = new AbortController();
|
|
160
|
-
const fetchTimeout = setTimeout(() => {
|
|
161
|
-
if (controller) {
|
|
162
|
-
controller.abort(`timeout after ${this.options.mock.timeout}`);
|
|
163
|
-
}
|
|
164
|
-
}, this.options.mock.timeout);
|
|
165
|
-
req.on('close', () => {
|
|
166
|
-
shared_1.logger.debug(logContext, 'remote client canceled the request');
|
|
167
|
-
clearTimeout(fetchTimeout);
|
|
168
|
-
if (controller) {
|
|
169
|
-
controller.abort('remote client canceled the request');
|
|
170
|
-
}
|
|
171
|
-
});
|
|
172
|
-
let targetResponse;
|
|
173
|
-
const hasKeepAlive = !!mockEntry.modifiedRequest.rawHeaders
|
|
174
|
-
.toHeaderPairs()
|
|
175
|
-
.find((h) => /^connection$/.test(h[0]) && /^keep-alive$/.test(h[1]));
|
|
176
|
-
try {
|
|
177
|
-
targetResponse = await fetch(mockEntry.modifiedRequest.url, {
|
|
178
|
-
redirect: 'manual',
|
|
179
|
-
headers: mockEntry.modifiedRequest.rawHeaders
|
|
180
|
-
.toHeaderPairs()
|
|
181
|
-
.filter((h) => !/^connection$/.test(h[0]) && !/^keep-alive$/.test(h[1])),
|
|
182
|
-
body: mockEntry.modifiedRequest.body,
|
|
183
|
-
method: mockEntry.modifiedRequest.method,
|
|
184
|
-
keepalive: !!hasKeepAlive,
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
finally {
|
|
188
|
-
controller = null;
|
|
189
|
-
clearTimeout(fetchTimeout);
|
|
190
|
-
}
|
|
191
|
-
const targetResponseBuffer = Buffer.from(await targetResponse.arrayBuffer());
|
|
192
|
-
const originalResponse = {
|
|
193
|
-
timestamp: Date.now(),
|
|
194
|
-
body: targetResponseBuffer.toString('binary'),
|
|
195
|
-
status: targetResponse.status,
|
|
196
|
-
rawHeaders: new shared_1.RawHeaders(...Array.from(targetResponse.headers.entries()).flatMap(([key, value]) => [key, value])),
|
|
197
|
-
};
|
|
198
|
-
shared_1.logger.debug({ ...logContext, originalResponse }, 'received response');
|
|
199
|
-
mockEntry.originalResponse = originalResponse;
|
|
200
|
-
let modifedResponse = {
|
|
201
|
-
...originalResponse,
|
|
202
|
-
rawHeaders: new shared_1.RawHeaders(...Array.from(targetResponse.headers.entries()).flatMap(([key, value]) => {
|
|
203
|
-
// 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)
|
|
204
|
-
return [
|
|
205
|
-
key,
|
|
206
|
-
isProxiedHostname
|
|
207
|
-
? value
|
|
208
|
-
: value.replace(new RegExp(`(?:^|\\b)(${unproxiedHostname.replace('.', '\\.')})(?:\\b|$)`, 'igm'), originalHostname),
|
|
209
|
-
];
|
|
210
|
-
})),
|
|
211
|
-
};
|
|
212
|
-
if ((_d = matchingRule === null || matchingRule === void 0 ? void 0 : matchingRule.actions) === null || _d === void 0 ? void 0 : _d.modifyResponse) {
|
|
213
|
-
modifedResponse = (_e = matchingRule === null || matchingRule === void 0 ? void 0 : matchingRule.actions) === null || _e === void 0 ? void 0 : _e.modifyResponse(mockEntry.modifiedRequest, originalResponse);
|
|
214
|
-
shared_1.logger.debug({ ...logContext, modifedResponse }, 'modified response');
|
|
215
|
-
}
|
|
216
|
-
mockEntry.modifiedResponse = modifedResponse;
|
|
217
|
-
if (matchingRule === null || matchingRule === void 0 ? void 0 : matchingRule.storeTraffic) {
|
|
218
|
-
this.trafficStore.set(requestUuid, mockEntry);
|
|
219
|
-
}
|
|
220
|
-
if (modifedResponse.status) {
|
|
221
|
-
res.status(modifedResponse.status);
|
|
222
|
-
}
|
|
223
|
-
if (modifedResponse.rawHeaders) {
|
|
224
|
-
for (const header of modifedResponse.rawHeaders.toHeaderPairs()) {
|
|
225
|
-
// since fetch decompresses responses we need to get rid of some headers
|
|
226
|
-
// TODO maybe could be handled better than just skipping, although express should add these back for new body
|
|
227
|
-
if (/^content-(?:length|encoding)$/i.test(header[0])) {
|
|
228
|
-
continue;
|
|
229
|
-
}
|
|
230
|
-
res.setHeader(header[0], header[1]);
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
res.end(Buffer.from(modifedResponse.body, 'binary'));
|
|
234
|
-
});
|
|
88
|
+
this.mockApp.all(/.*/, this.requestHandler);
|
|
235
89
|
this.mockApp.use((error, req, res, _next) => {
|
|
236
90
|
const ctx = requestContext_1.default.get(req);
|
|
237
91
|
const uuid = (ctx === null || ctx === void 0 ? void 0 : ctx.uuid) || (0, uuid_1.v4)();
|
|
@@ -242,10 +96,169 @@ class Mock {
|
|
|
242
96
|
});
|
|
243
97
|
return;
|
|
244
98
|
}
|
|
245
|
-
|
|
99
|
+
// eslint-disable-next-line no-console
|
|
100
|
+
console.error('mock server encountered a critical error. exiting');
|
|
246
101
|
process.exit(1);
|
|
247
102
|
});
|
|
248
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
|
+
}
|
|
249
262
|
start() {
|
|
250
263
|
if (this.server) {
|
|
251
264
|
throw new Error('mock server already started');
|
package/dist/ruleExecutor.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type * as Stuntman from '@stuntman/shared';
|
|
2
|
-
declare class RuleExecutor {
|
|
2
|
+
declare class RuleExecutor implements Stuntman.RuleExecutorInterface {
|
|
3
3
|
private _rules;
|
|
4
|
+
private rulesLock;
|
|
4
5
|
private get enabledRules();
|
|
5
6
|
constructor(rules?: Stuntman.Rule[]);
|
|
6
7
|
private hasExpired;
|
|
@@ -17,5 +18,5 @@ declare class RuleExecutor {
|
|
|
17
18
|
getRules(): Promise<readonly Stuntman.LiveRule[]>;
|
|
18
19
|
getRule(id: string): Promise<Stuntman.LiveRule | undefined>;
|
|
19
20
|
}
|
|
20
|
-
export declare const
|
|
21
|
+
export declare const getRuleExecutor: (mockUuid: string) => RuleExecutor;
|
|
21
22
|
export {};
|
package/dist/ruleExecutor.js
CHANGED
|
@@ -3,11 +3,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.
|
|
6
|
+
exports.getRuleExecutor = void 0;
|
|
7
7
|
const await_lock_1 = __importDefault(require("await-lock"));
|
|
8
8
|
const shared_1 = require("@stuntman/shared");
|
|
9
9
|
const rules_1 = require("./rules");
|
|
10
|
-
const
|
|
10
|
+
const ruleExecutors = {};
|
|
11
11
|
const transformMockRuleToLive = (rule) => {
|
|
12
12
|
var _a;
|
|
13
13
|
return {
|
|
@@ -28,6 +28,7 @@ class RuleExecutor {
|
|
|
28
28
|
.sort((a, b) => { var _a, _b; return ((_a = a.priority) !== null && _a !== void 0 ? _a : shared_1.DEFAULT_RULE_PRIORITY) - ((_b = b.priority) !== null && _b !== void 0 ? _b : shared_1.DEFAULT_RULE_PRIORITY); });
|
|
29
29
|
}
|
|
30
30
|
constructor(rules) {
|
|
31
|
+
this.rulesLock = new await_lock_1.default();
|
|
31
32
|
this._rules = (rules || []).map(transformMockRuleToLive);
|
|
32
33
|
}
|
|
33
34
|
hasExpired() {
|
|
@@ -38,7 +39,7 @@ class RuleExecutor {
|
|
|
38
39
|
if (!this.hasExpired()) {
|
|
39
40
|
return;
|
|
40
41
|
}
|
|
41
|
-
await rulesLock.acquireAsync();
|
|
42
|
+
await this.rulesLock.acquireAsync();
|
|
42
43
|
const now = Date.now();
|
|
43
44
|
try {
|
|
44
45
|
this._rules = this._rules.filter((r) => {
|
|
@@ -50,12 +51,12 @@ class RuleExecutor {
|
|
|
50
51
|
});
|
|
51
52
|
}
|
|
52
53
|
finally {
|
|
53
|
-
await rulesLock.release();
|
|
54
|
+
await this.rulesLock.release();
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
57
|
async addRule(rule, overwrite) {
|
|
57
58
|
await this.cleanUpExpired();
|
|
58
|
-
await rulesLock.acquireAsync();
|
|
59
|
+
await this.rulesLock.acquireAsync();
|
|
59
60
|
try {
|
|
60
61
|
if (this._rules.some((r) => r.id === rule.id)) {
|
|
61
62
|
if (!overwrite) {
|
|
@@ -69,7 +70,7 @@ class RuleExecutor {
|
|
|
69
70
|
return liveRule;
|
|
70
71
|
}
|
|
71
72
|
finally {
|
|
72
|
-
await rulesLock.release();
|
|
73
|
+
await this.rulesLock.release();
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
76
|
_removeRule(ruleOrId) {
|
|
@@ -83,17 +84,18 @@ class RuleExecutor {
|
|
|
83
84
|
}
|
|
84
85
|
async removeRule(ruleOrId) {
|
|
85
86
|
await this.cleanUpExpired();
|
|
86
|
-
await rulesLock.acquireAsync();
|
|
87
|
+
await this.rulesLock.acquireAsync();
|
|
87
88
|
try {
|
|
88
89
|
this._removeRule(ruleOrId);
|
|
89
90
|
}
|
|
90
91
|
finally {
|
|
91
|
-
await rulesLock.release();
|
|
92
|
+
await this.rulesLock.release();
|
|
92
93
|
}
|
|
93
94
|
}
|
|
94
95
|
enableRule(ruleOrId) {
|
|
95
96
|
this._rules.forEach((r) => {
|
|
96
97
|
if (r.id === (typeof ruleOrId === 'string' ? ruleOrId : ruleOrId.id)) {
|
|
98
|
+
r.counter = 0;
|
|
97
99
|
r.isEnabled = true;
|
|
98
100
|
shared_1.logger.debug({ ruleId: r.id }, 'rule enabled');
|
|
99
101
|
}
|
|
@@ -112,11 +114,17 @@ class RuleExecutor {
|
|
|
112
114
|
requestId: request.id,
|
|
113
115
|
};
|
|
114
116
|
const matchingRule = this.enabledRules.find((rule) => {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
117
|
+
try {
|
|
118
|
+
const matchResult = rule.matches(request);
|
|
119
|
+
shared_1.logger.trace({ ...logContext, matchResult }, `rule match attempt for ${rule.id}`);
|
|
120
|
+
if (typeof matchResult === 'boolean') {
|
|
121
|
+
return matchResult;
|
|
122
|
+
}
|
|
123
|
+
return matchResult.result;
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
shared_1.logger.error({ ...logContext, ruleId: rule === null || rule === void 0 ? void 0 : rule.id, error }, 'error in rule match function');
|
|
118
127
|
}
|
|
119
|
-
return matchResult.result;
|
|
120
128
|
});
|
|
121
129
|
if (!matchingRule) {
|
|
122
130
|
shared_1.logger.debug(logContext, 'no matching rule found');
|
|
@@ -124,7 +132,7 @@ class RuleExecutor {
|
|
|
124
132
|
}
|
|
125
133
|
const matchResult = matchingRule.matches(request);
|
|
126
134
|
logContext.ruleId = matchingRule.id;
|
|
127
|
-
shared_1.logger.debug(logContext, 'matching rule
|
|
135
|
+
shared_1.logger.debug({ ...logContext, matchResultMessage: typeof matchResult !== 'boolean' ? matchResult.description : null }, 'found matching rule');
|
|
128
136
|
const matchingRuleClone = Object.freeze(Object.assign({}, matchingRule));
|
|
129
137
|
++matchingRule.counter;
|
|
130
138
|
logContext.ruleCounter = matchingRule.counter;
|
|
@@ -132,6 +140,7 @@ class RuleExecutor {
|
|
|
132
140
|
matchingRule.counter = 0;
|
|
133
141
|
shared_1.logger.warn(logContext, "it's over 9000!!!");
|
|
134
142
|
}
|
|
143
|
+
// TODO check if that works
|
|
135
144
|
if (matchingRule.disableAfterUse) {
|
|
136
145
|
if (typeof matchingRule.disableAfterUse === 'boolean' || matchingRule.disableAfterUse <= matchingRule.counter) {
|
|
137
146
|
shared_1.logger.debug(logContext, 'disabling rule for future requests');
|
|
@@ -141,7 +150,7 @@ class RuleExecutor {
|
|
|
141
150
|
if (matchingRule.removeAfterUse) {
|
|
142
151
|
if (typeof matchingRule.removeAfterUse === 'boolean' || matchingRule.removeAfterUse <= matchingRule.counter) {
|
|
143
152
|
shared_1.logger.debug(logContext, 'removing rule for future requests');
|
|
144
|
-
this.removeRule(matchingRule);
|
|
153
|
+
await this.removeRule(matchingRule);
|
|
145
154
|
}
|
|
146
155
|
}
|
|
147
156
|
if (typeof matchResult !== 'boolean') {
|
|
@@ -169,4 +178,10 @@ class RuleExecutor {
|
|
|
169
178
|
return this._rules.find((r) => r.id === id);
|
|
170
179
|
}
|
|
171
180
|
}
|
|
172
|
-
|
|
181
|
+
const getRuleExecutor = (mockUuid) => {
|
|
182
|
+
if (!ruleExecutors[mockUuid]) {
|
|
183
|
+
ruleExecutors[mockUuid] = new RuleExecutor([...rules_1.DEFAULT_RULES, ...rules_1.CUSTOM_RULES].map((r) => ({ ...r, ttlSeconds: Infinity })));
|
|
184
|
+
}
|
|
185
|
+
return ruleExecutors[mockUuid];
|
|
186
|
+
};
|
|
187
|
+
exports.getRuleExecutor = getRuleExecutor;
|
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": {
|
|
@@ -32,19 +32,26 @@
|
|
|
32
32
|
"author": "Andrzej Pasterczyk",
|
|
33
33
|
"license": "MIT",
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@stuntman/shared": "^0.1.
|
|
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
|
-
"pug": "
|
|
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",
|
|
45
|
+
"undici": "5.20.0",
|
|
42
46
|
"uuid": "9.0.0"
|
|
43
47
|
},
|
|
44
48
|
"devDependencies": {
|
|
49
|
+
"@prettier/plugin-pug": "2.4.1",
|
|
45
50
|
"@types/express": "4.17.17",
|
|
51
|
+
"@types/glob": "8.1.0",
|
|
46
52
|
"@types/serialize-javascript": "5.0.2",
|
|
47
|
-
"@types/uuid": "9.0.0"
|
|
53
|
+
"@types/uuid": "9.0.0",
|
|
54
|
+
"prettier": "2.8.4"
|
|
48
55
|
},
|
|
49
56
|
"bin": {
|
|
50
57
|
"stuntman": "./dist/bin/stuntman.js"
|
|
@@ -60,7 +67,7 @@
|
|
|
60
67
|
"clean": "rm -fr dist",
|
|
61
68
|
"build": "tsc && cp -rv src/api/webgui dist/api",
|
|
62
69
|
"lint": "prettier --check . && eslint . --ext ts",
|
|
63
|
-
"lint:fix": "prettier --write ./src && eslint ./src --ext ts --fix",
|
|
70
|
+
"lint:fix": "prettier --write ./{src,test} && eslint ./{src,test} --ext ts --fix",
|
|
64
71
|
"start": "node ./dist/bin/stuntman.js",
|
|
65
72
|
"start:dev": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' ./src/bin/stuntman.ts",
|
|
66
73
|
"start:debug": "node --inspect-brk=0.0.0.0 ./node_modules/.bin/ts-node --transpile-only ./src/bin/stuntman.ts"
|
|
File without changes
|