@stuntman/server 0.1.4 → 0.1.6

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.
Files changed (46) hide show
  1. package/dist/api/api.d.ts +22 -0
  2. package/dist/api/api.js +182 -0
  3. package/dist/api/utils.d.ts +4 -0
  4. package/dist/api/utils.js +60 -0
  5. package/dist/api/validators.d.ts +3 -0
  6. package/dist/api/validators.js +124 -0
  7. package/dist/api/webgui/rules.pug +147 -0
  8. package/dist/api/webgui/style.css +28 -0
  9. package/dist/api/webgui/traffic.pug +37 -0
  10. package/dist/bin/stuntman.d.ts +2 -0
  11. package/dist/bin/stuntman.js +7 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.js +5 -0
  14. package/dist/ipUtils.d.ts +17 -0
  15. package/dist/ipUtils.js +101 -0
  16. package/dist/mock.d.ts +30 -0
  17. package/dist/mock.js +327 -0
  18. package/dist/requestContext.d.ts +9 -0
  19. package/dist/requestContext.js +18 -0
  20. package/dist/ruleExecutor.d.ts +22 -0
  21. package/dist/ruleExecutor.js +187 -0
  22. package/dist/rules/catchAll.d.ts +2 -0
  23. package/dist/rules/catchAll.js +15 -0
  24. package/dist/rules/echo.d.ts +2 -0
  25. package/dist/rules/echo.js +15 -0
  26. package/dist/rules/index.d.ts +3 -0
  27. package/dist/rules/index.js +70 -0
  28. package/dist/storage.d.ts +4 -0
  29. package/dist/storage.js +42 -0
  30. package/package.json +8 -5
  31. package/src/api/api.ts +225 -0
  32. package/src/api/utils.ts +69 -0
  33. package/src/api/validators.ts +132 -0
  34. package/src/api/webgui/rules.pug +147 -0
  35. package/src/api/webgui/style.css +28 -0
  36. package/src/api/webgui/traffic.pug +37 -0
  37. package/src/bin/stuntman.ts +8 -0
  38. package/src/index.ts +1 -0
  39. package/src/ipUtils.ts +83 -0
  40. package/src/mock.ts +382 -0
  41. package/src/requestContext.ts +23 -0
  42. package/src/ruleExecutor.ts +211 -0
  43. package/src/rules/catchAll.ts +14 -0
  44. package/src/rules/echo.ts +14 -0
  45. package/src/rules/index.ts +44 -0
  46. package/src/storage.ts +39 -0
@@ -0,0 +1,187 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getRuleExecutor = void 0;
7
+ const await_lock_1 = __importDefault(require("await-lock"));
8
+ const shared_1 = require("@stuntman/shared");
9
+ const rules_1 = require("./rules");
10
+ const ruleExecutors = {};
11
+ const transformMockRuleToLive = (rule) => {
12
+ var _a;
13
+ return {
14
+ ...rule,
15
+ counter: 0,
16
+ isEnabled: (_a = rule.isEnabled) !== null && _a !== void 0 ? _a : true,
17
+ createdTimestamp: Date.now(),
18
+ };
19
+ };
20
+ class RuleExecutor {
21
+ get enabledRules() {
22
+ if (!this._rules) {
23
+ return new Array();
24
+ }
25
+ const now = Date.now();
26
+ return this._rules
27
+ .filter((r) => (r.isEnabled && !Number.isFinite(r.ttlSeconds)) || r.createdTimestamp + r.ttlSeconds * 1000 > now)
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
+ }
30
+ constructor(rules) {
31
+ this.rulesLock = new await_lock_1.default();
32
+ this._rules = (rules || []).map(transformMockRuleToLive);
33
+ }
34
+ hasExpired() {
35
+ const now = Date.now();
36
+ return this._rules.some((r) => Number.isFinite(r.ttlSeconds) && r.createdTimestamp + r.ttlSeconds * 1000 < now);
37
+ }
38
+ async cleanUpExpired() {
39
+ if (!this.hasExpired()) {
40
+ return;
41
+ }
42
+ await this.rulesLock.acquireAsync();
43
+ const now = Date.now();
44
+ try {
45
+ this._rules = this._rules.filter((r) => {
46
+ const shouldKeep = !Number.isFinite(r.ttlSeconds) || r.createdTimestamp + r.ttlSeconds * 1000 > now;
47
+ if (!shouldKeep) {
48
+ shared_1.logger.debug({ ruleId: r.id }, 'removing expired rule');
49
+ }
50
+ return shouldKeep;
51
+ });
52
+ }
53
+ finally {
54
+ await this.rulesLock.release();
55
+ }
56
+ }
57
+ async addRule(rule, overwrite) {
58
+ await this.cleanUpExpired();
59
+ await this.rulesLock.acquireAsync();
60
+ try {
61
+ if (this._rules.some((r) => r.id === rule.id)) {
62
+ if (!overwrite) {
63
+ throw new shared_1.AppError({ httpCode: shared_1.HttpCode.CONFLICT, message: 'rule with given ID already exists' });
64
+ }
65
+ this._removeRule(rule.id);
66
+ }
67
+ const liveRule = transformMockRuleToLive(rule);
68
+ this._rules.push(liveRule);
69
+ shared_1.logger.debug(liveRule, 'rule added');
70
+ return liveRule;
71
+ }
72
+ finally {
73
+ await this.rulesLock.release();
74
+ }
75
+ }
76
+ _removeRule(ruleOrId) {
77
+ this._rules = this._rules.filter((r) => {
78
+ const notFound = r.id !== (typeof ruleOrId === 'string' ? ruleOrId : ruleOrId.id);
79
+ if (!notFound) {
80
+ shared_1.logger.debug({ ruleId: r.id }, 'rule removed');
81
+ }
82
+ return notFound;
83
+ });
84
+ }
85
+ async removeRule(ruleOrId) {
86
+ await this.cleanUpExpired();
87
+ await this.rulesLock.acquireAsync();
88
+ try {
89
+ this._removeRule(ruleOrId);
90
+ }
91
+ finally {
92
+ await this.rulesLock.release();
93
+ }
94
+ }
95
+ enableRule(ruleOrId) {
96
+ this._rules.forEach((r) => {
97
+ if (r.id === (typeof ruleOrId === 'string' ? ruleOrId : ruleOrId.id)) {
98
+ r.counter = 0;
99
+ r.isEnabled = true;
100
+ shared_1.logger.debug({ ruleId: r.id }, 'rule enabled');
101
+ }
102
+ });
103
+ }
104
+ disableRule(ruleOrId) {
105
+ this._rules.forEach((r) => {
106
+ if (r.id === (typeof ruleOrId === 'string' ? ruleOrId : ruleOrId.id)) {
107
+ r.isEnabled = false;
108
+ shared_1.logger.debug({ ruleId: r.id }, 'rule disabled');
109
+ }
110
+ });
111
+ }
112
+ async findMatchingRule(request) {
113
+ const logContext = {
114
+ requestId: request.id,
115
+ };
116
+ const matchingRule = this.enabledRules.find((rule) => {
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');
127
+ }
128
+ });
129
+ if (!matchingRule) {
130
+ shared_1.logger.debug(logContext, 'no matching rule found');
131
+ return null;
132
+ }
133
+ const matchResult = matchingRule.matches(request);
134
+ logContext.ruleId = matchingRule.id;
135
+ shared_1.logger.debug({ ...logContext, matchResultMessage: typeof matchResult !== 'boolean' ? matchResult.description : null }, 'found matching rule');
136
+ const matchingRuleClone = Object.freeze(Object.assign({}, matchingRule));
137
+ ++matchingRule.counter;
138
+ logContext.ruleCounter = matchingRule.counter;
139
+ if (Number.isNaN(matchingRule.counter) || !Number.isFinite(matchingRule.counter)) {
140
+ matchingRule.counter = 0;
141
+ shared_1.logger.warn(logContext, "it's over 9000!!!");
142
+ }
143
+ // TODO check if that works
144
+ if (matchingRule.disableAfterUse) {
145
+ if (typeof matchingRule.disableAfterUse === 'boolean' || matchingRule.disableAfterUse <= matchingRule.counter) {
146
+ shared_1.logger.debug(logContext, 'disabling rule for future requests');
147
+ this.disableRule(matchingRule);
148
+ }
149
+ }
150
+ if (matchingRule.removeAfterUse) {
151
+ if (typeof matchingRule.removeAfterUse === 'boolean' || matchingRule.removeAfterUse <= matchingRule.counter) {
152
+ shared_1.logger.debug(logContext, 'removing rule for future requests');
153
+ await this.removeRule(matchingRule);
154
+ }
155
+ }
156
+ if (typeof matchResult !== 'boolean') {
157
+ if (matchResult.disableRuleIds && matchResult.disableRuleIds.length > 0) {
158
+ shared_1.logger.debug({ ...logContext, disableRuleIds: matchResult.disableRuleIds }, 'disabling rules based on matchResult');
159
+ for (const ruleId of matchResult.disableRuleIds) {
160
+ this.disableRule(ruleId);
161
+ }
162
+ }
163
+ if (matchResult.enableRuleIds && matchResult.enableRuleIds.length > 0) {
164
+ shared_1.logger.debug({ ...logContext, disableRuleIds: matchResult.disableRuleIds }, 'enabling rules based on matchResult');
165
+ for (const ruleId of matchResult.enableRuleIds) {
166
+ this.enableRule(ruleId);
167
+ }
168
+ }
169
+ }
170
+ return matchingRuleClone;
171
+ }
172
+ async getRules() {
173
+ await this.cleanUpExpired();
174
+ return this._rules;
175
+ }
176
+ async getRule(id) {
177
+ await this.cleanUpExpired();
178
+ return this._rules.find((r) => r.id === id);
179
+ }
180
+ }
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;
@@ -0,0 +1,2 @@
1
+ import type * as Stuntman from '@stuntman/shared';
2
+ export declare const catchAllRule: Stuntman.DeployedRule;
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.catchAllRule = void 0;
4
+ const shared_1 = require("@stuntman/shared");
5
+ exports.catchAllRule = {
6
+ id: shared_1.CATCH_RULE_NAME,
7
+ matches: () => true,
8
+ priority: shared_1.CATCH_ALL_RULE_PRIORITY,
9
+ actions: {
10
+ mockResponse: (req) => ({
11
+ body: `Request received by Stuntman mock <pre>${JSON.stringify(req, null, 4)}</pre>`,
12
+ status: 200,
13
+ }),
14
+ },
15
+ };
@@ -0,0 +1,2 @@
1
+ import type * as Stuntman from '@stuntman/shared';
2
+ export declare const echoRule: Stuntman.DeployedRule;
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.echoRule = void 0;
4
+ const shared_1 = require("@stuntman/shared");
5
+ exports.echoRule = {
6
+ id: 'internal/echo',
7
+ priority: shared_1.DEFAULT_RULE_PRIORITY + 1,
8
+ matches: (req) => /https?:\/\/echo\/.*/.test(req.url),
9
+ actions: {
10
+ mockResponse: (req) => ({
11
+ body: req,
12
+ status: 200,
13
+ }),
14
+ },
15
+ };
@@ -0,0 +1,3 @@
1
+ import type * as Stuntman from '@stuntman/shared';
2
+ export declare const DEFAULT_RULES: Stuntman.DeployedRule[];
3
+ export declare const CUSTOM_RULES: Stuntman.DeployedRule[];
@@ -0,0 +1,70 @@
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
+ };
28
+ Object.defineProperty(exports, "__esModule", { value: true });
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"));
33
+ const catchAll_1 = require("./catchAll");
34
+ const echo_1 = require("./echo");
35
+ const shared_1 = require("@stuntman/shared");
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();
@@ -0,0 +1,4 @@
1
+ import LRUCache from 'lru-cache';
2
+ import type * as Stuntman from '@stuntman/shared';
3
+ export declare const getTrafficStore: (key: string, options?: Stuntman.StorageConfig) => LRUCache<string, Stuntman.LogEntry>;
4
+ export declare const getDnsResolutionCache: (key: string) => LRUCache<string, string>;
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getDnsResolutionCache = exports.getTrafficStore = void 0;
7
+ const lru_cache_1 = __importDefault(require("lru-cache"));
8
+ const object_sizeof_1 = __importDefault(require("object-sizeof"));
9
+ const DNS_CACHE_OPTIONS = {
10
+ max: 1000,
11
+ ttl: 1000 * 60 * 15,
12
+ allowStale: false,
13
+ updateAgeOnGet: false,
14
+ updateAgeOnHas: false,
15
+ };
16
+ const trafficStoreInstances = {};
17
+ const dnsResolutionCacheInstances = {};
18
+ const getTrafficStore = (key, options) => {
19
+ if (!(key in trafficStoreInstances)) {
20
+ if (!options) {
21
+ throw new Error('initialize with options first');
22
+ }
23
+ trafficStoreInstances[key] = new lru_cache_1.default({
24
+ max: options.limitCount,
25
+ maxSize: options.limitSize,
26
+ ttl: options.ttl,
27
+ allowStale: false,
28
+ updateAgeOnGet: false,
29
+ updateAgeOnHas: false,
30
+ sizeCalculation: (value) => (0, object_sizeof_1.default)(value),
31
+ });
32
+ }
33
+ return trafficStoreInstances[key];
34
+ };
35
+ exports.getTrafficStore = getTrafficStore;
36
+ const getDnsResolutionCache = (key) => {
37
+ if (!(key in dnsResolutionCacheInstances)) {
38
+ dnsResolutionCacheInstances[key] = new lru_cache_1.default(DNS_CACHE_OPTIONS);
39
+ }
40
+ return dnsResolutionCacheInstances[key];
41
+ };
42
+ exports.getDnsResolutionCache = getDnsResolutionCache;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stuntman/server",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Stuntman - HTTP proxy / mock server with API",
5
5
  "main": "dist/index.js",
6
6
  "repository": {
@@ -41,29 +41,32 @@
41
41
  "pug": "3.0.2",
42
42
  "serialize-javascript": "6.0.1",
43
43
  "ts-import": "4.0.0-beta.10",
44
- "typescript": "4.9.5",
45
44
  "undici": "5.20.0",
46
45
  "uuid": "9.0.0"
47
46
  },
48
47
  "devDependencies": {
48
+ "@jest/globals": "29.4.3",
49
49
  "@prettier/plugin-pug": "2.4.1",
50
50
  "@types/express": "4.17.17",
51
51
  "@types/glob": "8.1.0",
52
52
  "@types/serialize-javascript": "5.0.2",
53
53
  "@types/uuid": "9.0.0",
54
- "prettier": "2.8.4"
54
+ "jest": "29.4.3",
55
+ "prettier": "2.8.4",
56
+ "typescript": "4.9.5"
55
57
  },
56
58
  "bin": {
57
59
  "stuntman": "./dist/bin/stuntman.js"
58
60
  },
59
61
  "files": [
60
- "dist/",
62
+ "src/**",
63
+ "dist/**",
61
64
  "README.md",
62
65
  "LICENSE",
63
66
  "CHANGELOG.md"
64
67
  ],
65
68
  "scripts": {
66
- "test": "echo \"Error: no test specified\" && exit 1",
69
+ "test": "SUPPRESS_NO_CONFIG_WARNING=1 jest",
67
70
  "clean": "rm -fr dist",
68
71
  "build": "tsc && cp -rv src/api/webgui dist/api",
69
72
  "lint": "prettier --check . && eslint . --ext ts",
package/src/api/api.ts ADDED
@@ -0,0 +1,225 @@
1
+ import http from 'http';
2
+ import express, { NextFunction, Request, Response, Express as ExpressServer } from 'express';
3
+ import { v4 as uuidv4 } from 'uuid';
4
+ import { getTrafficStore } from '../storage';
5
+ import { getRuleExecutor } from '../ruleExecutor';
6
+ import { logger, AppError, HttpCode, MAX_RULE_TTL_SECONDS, stringify, INDEX_DTS } from '@stuntman/shared';
7
+ import type * as Stuntman from '@stuntman/shared';
8
+ import RequestContext from '../requestContext';
9
+ import serializeJavascript from 'serialize-javascript';
10
+ import LRUCache from 'lru-cache';
11
+ import { validateDeserializedRule } from './validators';
12
+ import { deserializeRule, escapedSerialize, liveRuleToRule } from './utils';
13
+
14
+ type ApiOptions = Stuntman.ApiConfig & {
15
+ mockUuid: string;
16
+ };
17
+
18
+ const API_KEY_HEADER = 'x-api-key';
19
+
20
+ export class API {
21
+ protected options: Required<ApiOptions>;
22
+ protected apiApp: ExpressServer;
23
+ trafficStore: LRUCache<string, Stuntman.LogEntry>;
24
+ server: http.Server | null = null;
25
+ auth: (req: Request, type: 'read' | 'write') => void;
26
+ authReadOnly: (req: Request, res: Response, next: NextFunction) => void;
27
+ authReadWrite: (req: Request, res: Response, next: NextFunction) => void;
28
+
29
+ constructor(options: ApiOptions, webGuiOptions?: Stuntman.WebGuiConfig) {
30
+ if (!options.apiKeyReadOnly !== !options.apiKeyReadWrite) {
31
+ throw new Error('apiKeyReadOnly and apiKeyReadWrite options need to be set either both or none');
32
+ }
33
+ this.options = options;
34
+
35
+ this.trafficStore = getTrafficStore(this.options.mockUuid);
36
+ this.apiApp = express();
37
+
38
+ this.apiApp.use(express.json());
39
+ this.apiApp.use(express.text());
40
+
41
+ this.auth = (req: Request, type: 'read' | 'write'): void => {
42
+ if (!this.options.apiKeyReadOnly && !this.options.apiKeyReadWrite) {
43
+ return;
44
+ }
45
+ const hasValidReadKey = req.header(API_KEY_HEADER) === this.options.apiKeyReadOnly;
46
+ const hasValidWriteKey = req.header(API_KEY_HEADER) === this.options.apiKeyReadWrite;
47
+ const hasValidKey = type === 'read' ? hasValidReadKey || hasValidWriteKey : hasValidWriteKey;
48
+ if (!hasValidKey) {
49
+ throw new AppError({ httpCode: HttpCode.UNAUTHORIZED, message: 'unauthorized' });
50
+ }
51
+ return;
52
+ };
53
+
54
+ this.authReadOnly = (req: Request, res: Response, next: NextFunction): void => {
55
+ this.auth(req, 'read');
56
+ next();
57
+ };
58
+
59
+ this.authReadWrite = (req: Request, res: Response, next: NextFunction): void => {
60
+ this.auth(req, 'write');
61
+ next();
62
+ };
63
+
64
+ this.apiApp.use((req: Request, res: Response, next: NextFunction) => {
65
+ RequestContext.bind(req, this.options.mockUuid);
66
+ next();
67
+ });
68
+
69
+ this.apiApp.get('/rule', this.authReadOnly, async (req, res) => {
70
+ res.send(stringify(await getRuleExecutor(this.options.mockUuid).getRules()));
71
+ });
72
+
73
+ this.apiApp.get('/rule/:ruleId', this.authReadOnly, async (req, res) => {
74
+ res.send(stringify(await getRuleExecutor(this.options.mockUuid).getRule(req.params.ruleId)));
75
+ });
76
+
77
+ this.apiApp.get('/rule/:ruleId/disable', this.authReadWrite, (req, res) => {
78
+ getRuleExecutor(this.options.mockUuid).disableRule(req.params.ruleId);
79
+ res.send();
80
+ });
81
+
82
+ this.apiApp.get('/rule/:ruleId/enable', this.authReadWrite, (req, res) => {
83
+ getRuleExecutor(this.options.mockUuid).enableRule(req.params.ruleId);
84
+ res.send();
85
+ });
86
+
87
+ this.apiApp.post(
88
+ '/rule',
89
+ this.authReadWrite,
90
+ async (req: Request<object, string, Stuntman.SerializedRule>, res: Response) => {
91
+ const deserializedRule = deserializeRule(req.body);
92
+ validateDeserializedRule(deserializedRule);
93
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
94
+ // @ts-ignore
95
+ const rule = await getRuleExecutor(this.options.mockUuid).addRule(deserializedRule);
96
+ res.send(stringify(rule));
97
+ }
98
+ );
99
+
100
+ this.apiApp.get('/rule/:ruleId/remove', this.authReadWrite, async (req, res) => {
101
+ await getRuleExecutor(this.options.mockUuid).removeRule(req.params.ruleId);
102
+ res.send();
103
+ });
104
+
105
+ this.apiApp.get('/traffic', this.authReadOnly, (req, res) => {
106
+ const serializedTraffic: Stuntman.LogEntry[] = [];
107
+ for (const value of this.trafficStore.values()) {
108
+ serializedTraffic.push(value);
109
+ }
110
+ res.json(serializedTraffic);
111
+ });
112
+
113
+ this.apiApp.get('/traffic/:ruleIdOrLabel', this.authReadOnly, (req, res) => {
114
+ const serializedTraffic: Stuntman.LogEntry[] = [];
115
+ for (const value of this.trafficStore.values()) {
116
+ if (value.mockRuleId === req.params.ruleIdOrLabel || (value.labels || []).includes(req.params.ruleIdOrLabel)) {
117
+ serializedTraffic.push(value);
118
+ }
119
+ }
120
+ res.json(serializedTraffic);
121
+ });
122
+
123
+ if (!webGuiOptions?.disabled) {
124
+ this.apiApp.set('views', __dirname + '/webgui');
125
+ this.apiApp.set('view engine', 'pug');
126
+ this.initWebGui();
127
+ }
128
+
129
+ this.apiApp.all(/.*/, (req: Request, res: Response) => res.status(404).send());
130
+
131
+ this.apiApp.use((error: Error | AppError, req: Request, res: Response) => {
132
+ const ctx: RequestContext | null = RequestContext.get(req);
133
+ const uuid = ctx?.uuid || uuidv4();
134
+ if (error instanceof AppError && error.isOperational && res) {
135
+ logger.error(error);
136
+ res.status(error.httpCode).json({
137
+ error: { message: error.message, httpCode: error.httpCode, stack: error.stack },
138
+ });
139
+ return;
140
+ }
141
+ logger.error({ message: error.message, stack: error.stack, name: error.name, uuid }, 'unexpected error');
142
+ if (res) {
143
+ res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
144
+ error: { message: error.message, httpCode: HttpCode.INTERNAL_SERVER_ERROR, uuid },
145
+ });
146
+ return;
147
+ }
148
+ // eslint-disable-next-line no-console
149
+ console.log('API server encountered a critical error. Exiting');
150
+ process.exit(1);
151
+ });
152
+ }
153
+
154
+ private initWebGui() {
155
+ this.apiApp.get('/webgui/rules', this.authReadOnly, async (req, res) => {
156
+ const rules: Record<string, string> = {};
157
+ for (const rule of await getRuleExecutor(this.options.mockUuid).getRules()) {
158
+ rules[rule.id] = serializeJavascript(liveRuleToRule(rule), { unsafe: true });
159
+ }
160
+ res.render('rules', { rules: escapedSerialize(rules), INDEX_DTS, ruleKeys: Object.keys(rules) });
161
+ });
162
+
163
+ this.apiApp.get('/webgui/traffic', this.authReadOnly, async (req, res) => {
164
+ const serializedTraffic: Stuntman.LogEntry[] = [];
165
+ for (const value of this.trafficStore.values()) {
166
+ serializedTraffic.push(value);
167
+ }
168
+ res.render('traffic', {
169
+ traffic: JSON.stringify(
170
+ serializedTraffic.sort((a, b) => b.originalRequest.timestamp - a.originalRequest.timestamp)
171
+ ),
172
+ });
173
+ });
174
+
175
+ // TODO make webui way better and safer, nicer formatting, eslint/prettier, blackjack and hookers
176
+
177
+ this.apiApp.post('/webgui/rules/unsafesave', this.authReadWrite, async (req, res) => {
178
+ const rule: Stuntman.Rule = new Function(req.body)();
179
+ if (
180
+ !rule ||
181
+ !rule.id ||
182
+ typeof rule.matches !== 'function' ||
183
+ typeof rule.ttlSeconds !== 'number' ||
184
+ rule.ttlSeconds > MAX_RULE_TTL_SECONDS
185
+ ) {
186
+ throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'Invalid rule' });
187
+ }
188
+ await getRuleExecutor(this.options.mockUuid).addRule(
189
+ {
190
+ id: rule.id,
191
+ matches: rule.matches,
192
+ ttlSeconds: rule.ttlSeconds,
193
+ actions: rule.actions,
194
+ ...(rule.disableAfterUse !== undefined && { disableAfterUse: rule.disableAfterUse }),
195
+ ...(rule.isEnabled !== undefined && { isEnabled: rule.isEnabled }),
196
+ ...(rule.labels !== undefined && { labels: rule.labels }),
197
+ ...(rule.priority !== undefined && { priority: rule.priority }),
198
+ ...(rule.removeAfterUse !== undefined && { removeAfterUse: rule.removeAfterUse }),
199
+ ...(rule.storeTraffic !== undefined && { storeTraffic: rule.storeTraffic }),
200
+ },
201
+ true
202
+ );
203
+ res.send();
204
+ });
205
+ }
206
+
207
+ public start() {
208
+ if (this.server) {
209
+ throw new Error('mock server already started');
210
+ }
211
+ this.server = this.apiApp.listen(this.options.port, () => {
212
+ logger.info(`API listening on ${this.options.port}`);
213
+ });
214
+ }
215
+
216
+ public stop() {
217
+ if (!this.server) {
218
+ throw new Error('mock server not started');
219
+ }
220
+ this.server.close((error) => {
221
+ logger.warn(error, 'problem closing server');
222
+ this.server = null;
223
+ });
224
+ }
225
+ }