@stuntman/client 0.1.3 → 0.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stuntman/client",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Stuntman - HTTP proxy / mock API client",
5
5
  "main": "dist/index.js",
6
6
  "repository": {
@@ -45,7 +45,8 @@
45
45
  "typescript": "4.9.5"
46
46
  },
47
47
  "files": [
48
- "dist/",
48
+ "src/**",
49
+ "dist/**",
49
50
  "README.md",
50
51
  "LICENSE",
51
52
  "CHANGELOG.md"
@@ -0,0 +1,189 @@
1
+ import serializeJavascript from 'serialize-javascript';
2
+ import { ClientError } from './clientError';
3
+ import { DEFAULT_API_PORT } from '@stuntman/shared';
4
+ import type * as Stuntman from '@stuntman/shared';
5
+
6
+ type ClientOptions = {
7
+ protocol?: 'http' | 'https';
8
+ host?: string;
9
+ port?: number;
10
+ timeout?: number;
11
+ apiKey?: string;
12
+ };
13
+
14
+ const SERIALIZE_JAVASCRIPT_OPTIONS: serializeJavascript.SerializeJSOptions = {
15
+ unsafe: true,
16
+ ignoreFunction: true,
17
+ };
18
+
19
+ const getFunctionParams = (func: () => any) => {
20
+ const funstr = func.toString();
21
+ const params = funstr.slice(funstr.indexOf('(') + 1, funstr.indexOf(')')).match(/([^\s,]+)/g) || new Array<string>();
22
+ if (params.includes('=')) {
23
+ throw new Error('default argument values are not supported');
24
+ }
25
+ return params;
26
+ };
27
+
28
+ const serializeApiFunction = (fn: (...args: any[]) => any, variables?: Stuntman.LocalVariables): string => {
29
+ const variableInitializer: string[] = [];
30
+ const functionParams = getFunctionParams(fn);
31
+ if (variables) {
32
+ for (const varName of Object.keys(variables)) {
33
+ let varValue = variables[varName];
34
+ if (varValue === undefined || varValue === null || typeof varValue === 'number' || typeof varValue === 'boolean') {
35
+ varValue = `${varValue}`;
36
+ } else if (typeof varValue === 'string') {
37
+ varValue = `${serializeJavascript(variables[varName], SERIALIZE_JAVASCRIPT_OPTIONS)}`;
38
+ } else {
39
+ varValue = `eval('(${serializeJavascript(variables[varName], SERIALIZE_JAVASCRIPT_OPTIONS).replace(
40
+ /'/g,
41
+ "\\'"
42
+ )})')`;
43
+ }
44
+ variableInitializer.push(`const ${varName} = ${varValue};`);
45
+ }
46
+ }
47
+ const functionString = fn.toString();
48
+ const serializedHeader = `return ((${functionParams.map((_param, index) => `____arg${index}`).join(',')}) => {`;
49
+ const serializedParams = `${functionParams
50
+ .map((_param, index) => `const ${functionParams[index]} = ____arg${index};`)
51
+ .join('\n')}`;
52
+ const serializedVariables = `${variableInitializer.join('\n')}`;
53
+ // prettier-ignore
54
+ const serializedFunction = `return (${functionString.substring(0, functionString.indexOf('('))}()${functionString.substring(functionString.indexOf(')')+1)})(); })(${functionParams.map((_param, index) => `____arg${index}`).join(',')})`;
55
+ if (!serializedParams && !serializedVariables) {
56
+ return `${serializedHeader}${serializedFunction}`;
57
+ }
58
+ return [serializedHeader, serializedParams, serializedVariables, serializedFunction].filter((x) => !!x).join('\n');
59
+ };
60
+
61
+ const keysOf = <T extends object>(obj: T): Array<keyof T> => {
62
+ return Array.from(Object.keys(obj)) as any;
63
+ };
64
+ const serializeRemotableFunctions = <T>(obj: any): Stuntman.WithSerializedFunctions<T> => {
65
+ const objectKeys = keysOf(obj);
66
+ if (!objectKeys || objectKeys.length === 0) {
67
+ return obj;
68
+ }
69
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
70
+ // @ts-ignore
71
+ const output: WithSerializedFunctions<T> = {};
72
+ for (const key of objectKeys) {
73
+ if (typeof obj[key] === 'object') {
74
+ if ('localFn' in obj[key]) {
75
+ const remotableFunction = obj[key] as Stuntman.RemotableFunction<(...args: any[]) => any>;
76
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
77
+ // @ts-ignore
78
+ output[key] = {
79
+ remoteFn: serializeApiFunction(remotableFunction.localFn, remotableFunction.localVariables),
80
+ localFn: remotableFunction.localFn.toString(),
81
+ localVariables: serializeJavascript(remotableFunction.localVariables, SERIALIZE_JAVASCRIPT_OPTIONS),
82
+ };
83
+ } else {
84
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
85
+ // @ts-ignore
86
+ output[key] = serializeRemotableFunctions<any>(obj[key]);
87
+ }
88
+ } else {
89
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
90
+ // @ts-ignore
91
+ output[key] = obj[key];
92
+ }
93
+ }
94
+ return output;
95
+ };
96
+
97
+ export class Client {
98
+ // TODO websockets connection to API and hooks `onIntereceptedRequest`, `onInterceptedResponse`
99
+
100
+ private options: ClientOptions;
101
+
102
+ private get baseUrl() {
103
+ return `${this.options.protocol}://${this.options.host}${this.options.port ? `:${this.options.port}` : ''}`;
104
+ }
105
+
106
+ constructor(options?: ClientOptions) {
107
+ this.options = {
108
+ ...options,
109
+ timeout: options?.timeout || 60000,
110
+ host: options?.host || 'localhost',
111
+ protocol: options?.protocol || 'http',
112
+ port: options?.port || options?.protocol ? (options.protocol === 'https' ? 443 : 80) : DEFAULT_API_PORT,
113
+ };
114
+ }
115
+
116
+ private async fetch(url: RequestInfo, init?: RequestInit): Promise<Response> {
117
+ const controller = new AbortController();
118
+ const timeout = setTimeout(() => {
119
+ controller.abort();
120
+ }, this.options.timeout);
121
+ try {
122
+ const response = await fetch(url, {
123
+ ...init,
124
+ headers: {
125
+ ...(this.options.apiKey && { 'x-api-key': this.options.apiKey }),
126
+ ...init?.headers,
127
+ },
128
+ signal: init?.signal ?? controller.signal,
129
+ });
130
+ if (!response.ok) {
131
+ const text = await response.text();
132
+ let json: any;
133
+ try {
134
+ json = JSON.parse(text);
135
+ } catch (kiss) {
136
+ // and swallow
137
+ }
138
+ if ('error' in json) {
139
+ throw new ClientError(json.error);
140
+ }
141
+ throw new Error(`Unexpected errror: ${text}`);
142
+ }
143
+ return response;
144
+ } finally {
145
+ clearTimeout(timeout);
146
+ }
147
+ }
148
+
149
+ async getRules(): Promise<Stuntman.LiveRule[]> {
150
+ const response = await this.fetch(`${this.baseUrl}/rules`);
151
+ return response.json() as unknown as Promise<Stuntman.LiveRule[]>;
152
+ }
153
+
154
+ async getRule(id: string): Promise<Stuntman.LiveRule> {
155
+ const response = await this.fetch(`${this.baseUrl}/rule/${encodeURIComponent(id)}`);
156
+ return response.json() as unknown as Stuntman.LiveRule;
157
+ }
158
+
159
+ async disableRule(id: string): Promise<void> {
160
+ await this.fetch(`${this.baseUrl}/rule/${encodeURIComponent(id)}/disable`);
161
+ }
162
+
163
+ async enableRule(id: string): Promise<void> {
164
+ await this.fetch(`${this.baseUrl}/rule/${encodeURIComponent(id)}/enable`);
165
+ }
166
+
167
+ async removeRule(id: string): Promise<void> {
168
+ await this.fetch(`${this.baseUrl}/rule/${encodeURIComponent(id)}/remove`);
169
+ }
170
+
171
+ async addRule(rule: Stuntman.SerializableRule): Promise<Stuntman.Rule> {
172
+ const serializedRule = serializeRemotableFunctions<Stuntman.SerializableRule>(rule);
173
+ const response = await this.fetch(`${this.baseUrl}/rule`, {
174
+ method: 'POST',
175
+ body: JSON.stringify(serializedRule),
176
+ headers: { 'content-type': 'application/json' },
177
+ });
178
+ return response.json() as unknown as Stuntman.Rule;
179
+ }
180
+
181
+ // TODO improve filtering by timestamp from - to, multiple labels, etc.
182
+ async getTraffic(rule: Stuntman.Rule): Promise<Record<string, Stuntman.LogEntry>>;
183
+ async getTraffic(ruleIdOrLabel: string): Promise<Record<string, Stuntman.LogEntry>>;
184
+ async getTraffic(ruleOrIdOrLabel: string | Stuntman.Rule): Promise<Record<string, Stuntman.LogEntry>> {
185
+ const ruleId = typeof ruleOrIdOrLabel === 'object' ? ruleOrIdOrLabel.id : ruleOrIdOrLabel;
186
+ const response = await this.fetch(`${this.baseUrl}/traffic${ruleId ? `/${encodeURIComponent(ruleId)}` : ''}`);
187
+ return response.json() as unknown as Record<string, Stuntman.LogEntry>;
188
+ }
189
+ }
@@ -0,0 +1,22 @@
1
+ import { AppError } from '@stuntman/shared';
2
+ import type * as Stuntman from '@stuntman/shared';
3
+
4
+ export enum HttpCode {
5
+ OK = 200,
6
+ NO_CONTENT = 204,
7
+ BAD_REQUEST = 400,
8
+ UNAUTHORIZED = 401,
9
+ NOT_FOUND = 404,
10
+ CONFLICT = 409,
11
+ UNPROCESSABLE_ENTITY = 422,
12
+ INTERNAL_SERVER_ERROR = 500,
13
+ }
14
+
15
+ export class ClientError extends AppError {
16
+ public readonly originalStack?: string;
17
+
18
+ constructor(args: Stuntman.AppError & { stack?: string }) {
19
+ super(args);
20
+ this.originalStack = args.stack;
21
+ }
22
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { Client as StuntmanClient } from './apiClient';
2
+ export { ruleBuilder } from './ruleBuilder';
@@ -0,0 +1,623 @@
1
+ import { v4 as uuidv4 } from 'uuid';
2
+ import type * as Stuntman from '@stuntman/shared';
3
+ import { DEFAULT_RULE_PRIORITY, DEFAULT_RULE_TTL_SECONDS, MAX_RULE_TTL_SECONDS, MIN_RULE_TTL_SECONDS } from '@stuntman/shared';
4
+
5
+ type KeyValueMatcher = string | RegExp | { key: string; value?: string | RegExp };
6
+ type ObjectValueMatcher = string | RegExp | number | boolean | null;
7
+ type ObjectKeyValueMatcher = { key: string; value?: ObjectValueMatcher };
8
+ type GQLRequestMatcher = {
9
+ operationName?: string | RegExp;
10
+ variables?: ObjectKeyValueMatcher[];
11
+ query?: string | RegExp;
12
+ type?: 'query' | 'mutation';
13
+ methodName?: string | RegExp;
14
+ };
15
+
16
+ type MatchBuilderVariables = {
17
+ filter?: string | RegExp;
18
+ hostname?: string | RegExp;
19
+ pathname?: string | RegExp;
20
+ port?: number | string | RegExp;
21
+ searchParams?: KeyValueMatcher[];
22
+ headers?: KeyValueMatcher[];
23
+ bodyText?: string | RegExp | null;
24
+ bodyJson?: ObjectKeyValueMatcher[];
25
+ bodyGql?: GQLRequestMatcher;
26
+ };
27
+
28
+ // eslint-disable-next-line no-var
29
+ declare var matchBuilderVariables: MatchBuilderVariables;
30
+
31
+ // TODO add fluent match on multipart from data
32
+
33
+ class RuleBuilderBaseBase {
34
+ protected rule: Stuntman.SerializableRule;
35
+ protected _matchBuilderVariables: MatchBuilderVariables;
36
+
37
+ constructor(rule?: Stuntman.SerializableRule, _matchBuilderVariables?: MatchBuilderVariables) {
38
+ this._matchBuilderVariables = _matchBuilderVariables || {};
39
+ this.rule = rule || {
40
+ id: uuidv4(),
41
+ ttlSeconds: DEFAULT_RULE_TTL_SECONDS,
42
+ priority: DEFAULT_RULE_PRIORITY,
43
+ matches: {
44
+ localFn: (req: Stuntman.Request): Stuntman.RuleMatchResult => {
45
+ const ___url = new URL(req.url);
46
+ const ___headers = req.rawHeaders;
47
+
48
+ const arrayIndexerRegex = /\[(?<arrayIndex>[0-9]*)\]/i;
49
+ const matchObject = (
50
+ obj: any,
51
+ path: string,
52
+ value?: string | RegExp | number | boolean | null,
53
+ parentPath?: string
54
+ ): Exclude<Stuntman.RuleMatchResult, boolean> => {
55
+ if (!obj) {
56
+ return { result: false, description: `${parentPath} is falsey` };
57
+ }
58
+ const [rawKey, ...rest] = path.split('.');
59
+ const key = rawKey.replace(arrayIndexerRegex, '');
60
+ const shouldBeArray = arrayIndexerRegex.test(rawKey);
61
+ const arrayIndex =
62
+ (arrayIndexerRegex.exec(rawKey)?.groups?.arrayIndex || '').length > 0
63
+ ? Number(arrayIndexerRegex.exec(rawKey)?.groups?.arrayIndex)
64
+ : Number.NaN;
65
+ const actualValue = key ? obj[key] : obj;
66
+ const currentPath = `${parentPath ? `${parentPath}.` : ''}${rawKey}`;
67
+ if (value === undefined && actualValue === undefined) {
68
+ return { result: false, description: `${currentPath}=undefined` };
69
+ }
70
+ if (rest.length === 0) {
71
+ if (
72
+ shouldBeArray &&
73
+ (!Array.isArray(actualValue) ||
74
+ (Number.isInteger(arrayIndex) && actualValue.length <= Number(arrayIndex)))
75
+ ) {
76
+ return { result: false, description: `${currentPath} empty array` };
77
+ }
78
+ if (value === undefined) {
79
+ const result = shouldBeArray
80
+ ? !Number.isInteger(arrayIndex) || actualValue.length >= Number(arrayIndex)
81
+ : actualValue !== undefined;
82
+ return { result, description: `${currentPath}` };
83
+ }
84
+ if (!shouldBeArray) {
85
+ const result = value instanceof RegExp ? value.test(actualValue) : value === actualValue;
86
+ return { result, description: `${currentPath}` };
87
+ }
88
+ }
89
+ if (shouldBeArray) {
90
+ if (Number.isInteger(arrayIndex)) {
91
+ return matchObject(actualValue[Number(arrayIndex)], rest.join('.'), value, currentPath);
92
+ }
93
+ const hasArrayMatch = (actualValue as Array<any>).some(
94
+ (arrayValue) => matchObject(arrayValue, rest.join('.'), value, currentPath).result
95
+ );
96
+ return { result: hasArrayMatch, description: `array match ${currentPath}` };
97
+ }
98
+ if (typeof actualValue !== 'object') {
99
+ return { result: false, description: `${currentPath} not an object` };
100
+ }
101
+ return matchObject(actualValue, rest.join('.'), value, currentPath);
102
+ };
103
+
104
+ const ___matchesValue = (matcher: number | string | RegExp | undefined, value?: string | number): boolean => {
105
+ if (matcher === undefined) {
106
+ return true;
107
+ }
108
+ if (typeof matcher !== 'string' && !(matcher instanceof RegExp) && typeof matcher !== 'number') {
109
+ throw new Error('invalid matcher');
110
+ }
111
+ if (typeof matcher === 'string' && matcher !== value) {
112
+ return false;
113
+ }
114
+ if (matcher instanceof RegExp && (typeof value !== 'string' || !matcher.test(value))) {
115
+ return false;
116
+ }
117
+ if (typeof matcher === 'number' && (typeof value !== 'number' || matcher !== value)) {
118
+ return false;
119
+ }
120
+ return true;
121
+ };
122
+ if (!___matchesValue(matchBuilderVariables.filter, req.url)) {
123
+ return {
124
+ result: false,
125
+ description: `url ${req.url} doesn't match ${matchBuilderVariables.filter?.toString()}`,
126
+ };
127
+ }
128
+ if (!___matchesValue(matchBuilderVariables.hostname, ___url.hostname)) {
129
+ return {
130
+ result: false,
131
+ description: `hostname ${
132
+ ___url.hostname
133
+ } doesn't match ${matchBuilderVariables.hostname?.toString()}`,
134
+ };
135
+ }
136
+ if (!___matchesValue(matchBuilderVariables.pathname, ___url.pathname)) {
137
+ return {
138
+ result: false,
139
+ description: `pathname ${
140
+ ___url.pathname
141
+ } doesn't match ${matchBuilderVariables.pathname?.toString()}`,
142
+ };
143
+ }
144
+ if (matchBuilderVariables.port) {
145
+ const port =
146
+ ___url.port && ___url.port !== '' ? ___url.port : ___url.protocol === 'https:' ? '443' : '80';
147
+ if (
148
+ !___matchesValue(
149
+ matchBuilderVariables.port instanceof RegExp
150
+ ? matchBuilderVariables.port
151
+ : `${matchBuilderVariables.port}`,
152
+ port
153
+ )
154
+ ) {
155
+ return {
156
+ result: false,
157
+ description: `port ${port} doesn't match ${matchBuilderVariables.port?.toString()}`,
158
+ };
159
+ }
160
+ }
161
+ if (matchBuilderVariables.searchParams) {
162
+ for (const searchParamMatcher of matchBuilderVariables.searchParams) {
163
+ if (typeof searchParamMatcher === 'string') {
164
+ const result = ___url.searchParams.has(searchParamMatcher);
165
+ return { result, description: `searchParams.has("${searchParamMatcher}")` };
166
+ }
167
+ if (searchParamMatcher instanceof RegExp) {
168
+ const result = Array.from(___url.searchParams.keys()).some((key) => searchParamMatcher.test(key));
169
+ return { result, description: `searchParams.keys() matches ${searchParamMatcher.toString()}` };
170
+ }
171
+ if (!___url.searchParams.has(searchParamMatcher.key)) {
172
+ return { result: false, description: `searchParams.has("${searchParamMatcher.key}")` };
173
+ }
174
+ if (searchParamMatcher.value) {
175
+ const value = ___url.searchParams.get(searchParamMatcher.key);
176
+ if (value === null) {
177
+ return {
178
+ result: false,
179
+ description: `searchParams.get("${searchParamMatcher.key}") === null`,
180
+ };
181
+ }
182
+ if (!___matchesValue(searchParamMatcher.value, value)) {
183
+ return {
184
+ result: false,
185
+ description: `searchParams.get("${searchParamMatcher.key}") = "${searchParamMatcher.value}"`,
186
+ };
187
+ }
188
+ }
189
+ }
190
+ }
191
+ if (matchBuilderVariables.headers) {
192
+ for (const headerMatcher of matchBuilderVariables.headers) {
193
+ if (typeof headerMatcher === 'string') {
194
+ const result = ___headers.has(headerMatcher);
195
+ if (result) {
196
+ continue;
197
+ }
198
+ return { result: false, description: `headers.has("${headerMatcher}")` };
199
+ }
200
+ if (headerMatcher instanceof RegExp) {
201
+ const result = ___headers.toHeaderPairs().some(([key]) => headerMatcher.test(key));
202
+ if (result) {
203
+ continue;
204
+ }
205
+ return { result: false, description: `headers.keys matches ${headerMatcher.toString()}` };
206
+ }
207
+ if (!___headers.has(headerMatcher.key)) {
208
+ return { result: false, description: `headers.has("${headerMatcher.key}")` };
209
+ }
210
+ if (headerMatcher.value) {
211
+ const value = ___headers.get(headerMatcher.key);
212
+ if (value === null) {
213
+ return { result: false, description: `headers.get("${headerMatcher.key}") === null` };
214
+ }
215
+ if (!___matchesValue(headerMatcher.value, value)) {
216
+ return {
217
+ result: false,
218
+ description: `headerMatcher.get("${headerMatcher.key}") = "${headerMatcher.value}"`,
219
+ };
220
+ }
221
+ }
222
+ }
223
+ }
224
+ if (matchBuilderVariables.bodyText === null && !!req.body) {
225
+ return { result: false, description: `empty body` };
226
+ }
227
+ if (matchBuilderVariables.bodyText) {
228
+ if (!req.body) {
229
+ return { result: false, description: `empty body` };
230
+ }
231
+ if (matchBuilderVariables.bodyText instanceof RegExp) {
232
+ if (!___matchesValue(matchBuilderVariables.bodyText, req.body)) {
233
+ return {
234
+ result: false,
235
+ description: `body text doesn't match ${matchBuilderVariables.bodyText.toString()}`,
236
+ };
237
+ }
238
+ } else if (!req.body.includes(matchBuilderVariables.bodyText)) {
239
+ return {
240
+ result: false,
241
+ description: `body text doesn't include "${matchBuilderVariables.bodyText}"`,
242
+ };
243
+ }
244
+ }
245
+ if (matchBuilderVariables.bodyJson) {
246
+ let json: any;
247
+ try {
248
+ json = JSON.parse(req.body);
249
+ } catch (kiss) {
250
+ return { result: false, description: `unparseable json` };
251
+ }
252
+ if (!json) {
253
+ return { result: false, description: `empty json object` };
254
+ }
255
+ for (const jsonMatcher of Array.isArray(matchBuilderVariables.bodyJson)
256
+ ? matchBuilderVariables.bodyJson
257
+ : [matchBuilderVariables.bodyJson]) {
258
+ const matchObjectResult = matchObject(json, jsonMatcher.key, jsonMatcher.value);
259
+ if (!matchObjectResult.result) {
260
+ return { result: false, description: `$.${jsonMatcher.key} != "${jsonMatcher.value}"` };
261
+ }
262
+ }
263
+ }
264
+ if (matchBuilderVariables.bodyGql) {
265
+ if (!req.gqlBody) {
266
+ return { result: false, description: `not a gql body` };
267
+ }
268
+ if (!___matchesValue(matchBuilderVariables.bodyGql.methodName, req.gqlBody.methodName)) {
269
+ return {
270
+ result: false,
271
+ description: `methodName "${matchBuilderVariables.bodyGql.methodName}" !== "${req.gqlBody.methodName}"`,
272
+ };
273
+ }
274
+ if (!___matchesValue(matchBuilderVariables.bodyGql.operationName, req.gqlBody.operationName)) {
275
+ return {
276
+ result: false,
277
+ description: `operationName "${matchBuilderVariables.bodyGql.operationName}" !== "${req.gqlBody.operationName}"`,
278
+ };
279
+ }
280
+ if (!___matchesValue(matchBuilderVariables.bodyGql.query, req.gqlBody.query)) {
281
+ return {
282
+ result: false,
283
+ description: `query "${matchBuilderVariables.bodyGql.query}" !== "${req.gqlBody.query}"`,
284
+ };
285
+ }
286
+ if (!___matchesValue(matchBuilderVariables.bodyGql.type, req.gqlBody.type)) {
287
+ return {
288
+ result: false,
289
+ description: `type "${matchBuilderVariables.bodyGql.type}" !== "${req.gqlBody.type}"`,
290
+ };
291
+ }
292
+ if (!matchBuilderVariables.bodyGql.variables) {
293
+ return { result: true, description: `no variables to match` };
294
+ }
295
+ for (const jsonMatcher of Array.isArray(matchBuilderVariables.bodyGql.variables)
296
+ ? matchBuilderVariables.bodyGql.variables
297
+ : [matchBuilderVariables.bodyGql.variables]) {
298
+ const matchObjectResult = matchObject(req.gqlBody.variables, jsonMatcher.key, jsonMatcher.value);
299
+ if (!matchObjectResult.result) {
300
+ return {
301
+ result: false,
302
+ description: `GQL variable ${jsonMatcher.key} != "${jsonMatcher.value}". Detail: ${matchObjectResult.description}`,
303
+ };
304
+ }
305
+ }
306
+ }
307
+ return { result: true, description: 'match' };
308
+ },
309
+ localVariables: { matchBuilderVariables: this._matchBuilderVariables },
310
+ },
311
+ };
312
+ }
313
+ }
314
+
315
+ class RuleBuilderBase extends RuleBuilderBaseBase {
316
+ limitedUse(hitCount: number) {
317
+ if (this.rule.removeAfterUse) {
318
+ throw new Error(`limit already set at ${this.rule.removeAfterUse}`);
319
+ }
320
+ if (Number.isNaN(hitCount) || !Number.isFinite(hitCount) || !Number.isInteger(hitCount) || hitCount <= 0) {
321
+ throw new Error('Invalid hitCount');
322
+ }
323
+ this.rule.removeAfterUse = hitCount;
324
+ return this;
325
+ }
326
+
327
+ singleUse() {
328
+ return this.limitedUse(1);
329
+ }
330
+
331
+ storeTraffic() {
332
+ this.rule.storeTraffic = true;
333
+ return this;
334
+ }
335
+
336
+ disabled() {
337
+ this.rule.isEnabled = false;
338
+ }
339
+ }
340
+
341
+ class RuleBuilder extends RuleBuilderBase {
342
+ raisePriority(by?: number) {
343
+ if (this.rule.priority !== DEFAULT_RULE_PRIORITY) {
344
+ throw new Error('you should not alter rule priority more than once');
345
+ }
346
+ const subtract = by ?? 1;
347
+ if (subtract >= DEFAULT_RULE_PRIORITY) {
348
+ throw new Error(`Unable to raise priority over the default ${DEFAULT_RULE_PRIORITY}`);
349
+ }
350
+ this.rule.priority = DEFAULT_RULE_PRIORITY - subtract;
351
+ return this;
352
+ }
353
+
354
+ decreasePriority(by?: number) {
355
+ if (this.rule.priority !== DEFAULT_RULE_PRIORITY) {
356
+ throw new Error('you should not alter rule priority more than once');
357
+ }
358
+ const add = by ?? 1;
359
+ this.rule.priority = DEFAULT_RULE_PRIORITY + add;
360
+ return this;
361
+ }
362
+
363
+ customTtl(ttlSeconds: number) {
364
+ if (Number.isNaN(ttlSeconds) || !Number.isInteger(ttlSeconds) || !Number.isFinite(ttlSeconds) || ttlSeconds < 0) {
365
+ throw new Error('Invalid ttl');
366
+ }
367
+ if (ttlSeconds < MIN_RULE_TTL_SECONDS || ttlSeconds > MAX_RULE_TTL_SECONDS) {
368
+ throw new Error(
369
+ `ttl of ${ttlSeconds} seconds is outside range min: ${MIN_RULE_TTL_SECONDS}, max:${MAX_RULE_TTL_SECONDS}`
370
+ );
371
+ }
372
+ this.rule.ttlSeconds = ttlSeconds;
373
+ return this;
374
+ }
375
+
376
+ customId(id: string) {
377
+ this.rule.id = id;
378
+ return this;
379
+ }
380
+
381
+ onRequestTo(filter: string | RegExp): RuleBuilderInitialized {
382
+ this._matchBuilderVariables.filter = filter;
383
+ return new RuleBuilderInitialized(this.rule, this._matchBuilderVariables);
384
+ }
385
+
386
+ onRequestToHostname(hostname: string | RegExp): RuleBuilderInitialized {
387
+ this._matchBuilderVariables.hostname = hostname;
388
+ return new RuleBuilderInitialized(this.rule, this._matchBuilderVariables);
389
+ }
390
+
391
+ onRequestToPathname(pathname: string | RegExp): RuleBuilderInitialized {
392
+ this._matchBuilderVariables.pathname = pathname;
393
+ return new RuleBuilderInitialized(this.rule, this._matchBuilderVariables);
394
+ }
395
+
396
+ onRequestToPort(port: string | number | RegExp): RuleBuilderInitialized {
397
+ this._matchBuilderVariables.port = port;
398
+ return new RuleBuilderInitialized(this.rule, this._matchBuilderVariables);
399
+ }
400
+
401
+ onAnyRequest(): RuleBuilderInitialized {
402
+ return new RuleBuilderInitialized(this.rule, this._matchBuilderVariables);
403
+ }
404
+ }
405
+
406
+ class RuleBuilderInitialized extends RuleBuilderBase {
407
+ withHostname(hostname: string | RegExp) {
408
+ if (this._matchBuilderVariables.hostname) {
409
+ throw new Error('hostname already set');
410
+ }
411
+ this._matchBuilderVariables.hostname = hostname;
412
+ return this;
413
+ }
414
+
415
+ withPathname(pathname: string | RegExp) {
416
+ if (this._matchBuilderVariables.pathname) {
417
+ throw new Error('pathname already set');
418
+ }
419
+ this._matchBuilderVariables.pathname = pathname;
420
+ return this;
421
+ }
422
+
423
+ withPort(port: number | string | RegExp) {
424
+ if (this._matchBuilderVariables.port) {
425
+ throw new Error('port already set');
426
+ }
427
+ this._matchBuilderVariables.port = port;
428
+ return this;
429
+ }
430
+
431
+ withSearchParam(key: string | RegExp): RuleBuilderInitialized;
432
+ withSearchParam(key: string, value?: string | RegExp): RuleBuilderInitialized;
433
+ withSearchParam(key: string | RegExp, value?: string | RegExp): RuleBuilderInitialized {
434
+ if (!this._matchBuilderVariables.searchParams) {
435
+ this._matchBuilderVariables.searchParams = [];
436
+ }
437
+ if (!key) {
438
+ throw new Error('key cannot be empty');
439
+ }
440
+ if (!value) {
441
+ this._matchBuilderVariables.searchParams.push(key);
442
+ return this;
443
+ }
444
+ if (key instanceof RegExp) {
445
+ throw new Error('Unsupported regex param key with value');
446
+ }
447
+ this._matchBuilderVariables.searchParams.push({ key, value });
448
+ return this;
449
+ }
450
+
451
+ withSearchParams(params: KeyValueMatcher[]): RuleBuilderInitialized {
452
+ if (!this._matchBuilderVariables.searchParams) {
453
+ this._matchBuilderVariables.searchParams = [];
454
+ }
455
+ for (const param of params) {
456
+ if (typeof param === 'string' || param instanceof RegExp) {
457
+ this.withSearchParam(param);
458
+ } else {
459
+ this.withSearchParam(param.key, param.value);
460
+ }
461
+ }
462
+ return this;
463
+ }
464
+
465
+ withHeader(key: string | RegExp): RuleBuilderInitialized;
466
+ withHeader(key: string, value?: string | RegExp): RuleBuilderInitialized;
467
+ withHeader(key: string | RegExp, value?: string | RegExp): RuleBuilderInitialized {
468
+ if (!this._matchBuilderVariables.headers) {
469
+ this._matchBuilderVariables.headers = [];
470
+ }
471
+ if (!key) {
472
+ throw new Error('key cannot be empty');
473
+ }
474
+ if (!value) {
475
+ this._matchBuilderVariables.headers.push(key);
476
+ return this;
477
+ }
478
+ if (key instanceof RegExp) {
479
+ throw new Error('Unsupported regex param key with value');
480
+ }
481
+ this._matchBuilderVariables.headers.push({ key, value });
482
+ return this;
483
+ }
484
+
485
+ withHeaders(...headers: KeyValueMatcher[]): RuleBuilderInitialized {
486
+ if (!this._matchBuilderVariables.headers) {
487
+ this._matchBuilderVariables.headers = [];
488
+ }
489
+ for (const header of headers) {
490
+ if (typeof header === 'string' || header instanceof RegExp) {
491
+ this.withHeader(header);
492
+ } else {
493
+ this.withHeader(header.key, header.value);
494
+ }
495
+ }
496
+ return this;
497
+ }
498
+
499
+ withBodyText(includes: string): RuleBuilderInitialized;
500
+ withBodyText(matches: RegExp): RuleBuilderInitialized;
501
+ withBodyText(includesOrMatches: string | RegExp): RuleBuilderInitialized {
502
+ if (this._matchBuilderVariables.bodyText) {
503
+ throw new Error('bodyText already set');
504
+ }
505
+ if (this._matchBuilderVariables.bodyText === null) {
506
+ throw new Error('cannot use both withBodyText and withoutBody');
507
+ }
508
+ this._matchBuilderVariables.bodyText = includesOrMatches;
509
+ return this;
510
+ }
511
+
512
+ withoutBody(): RuleBuilderInitialized {
513
+ if (this._matchBuilderVariables.bodyText) {
514
+ throw new Error('cannot use both withBodyText and withoutBody');
515
+ }
516
+ this._matchBuilderVariables.bodyText = null;
517
+ return this;
518
+ }
519
+
520
+ withBodyJson(hasKey: string): RuleBuilderInitialized;
521
+ withBodyJson(hasKey: string, withValue: ObjectValueMatcher): RuleBuilderInitialized;
522
+ withBodyJson(matches: ObjectKeyValueMatcher): RuleBuilderInitialized;
523
+ withBodyJson(keyOrMatcher: string | ObjectKeyValueMatcher, withValue?: ObjectValueMatcher): RuleBuilderInitialized {
524
+ const keyRegex = /^(?:(?:[a-z0-9_-]+)|(?:\[[0-9]*\]))(?:\.(?:(?:[a-z0-9_-]+)|(?:\[[0-9]*\])))*$/i;
525
+ if (!this._matchBuilderVariables.bodyJson) {
526
+ this._matchBuilderVariables.bodyJson = [];
527
+ }
528
+ if (typeof keyOrMatcher === 'string') {
529
+ if (!keyRegex.test(keyOrMatcher)) {
530
+ throw new Error(`invalid key "${keyOrMatcher}"`);
531
+ }
532
+ this._matchBuilderVariables.bodyJson.push({ key: keyOrMatcher, value: withValue });
533
+ return this;
534
+ }
535
+ if (withValue !== undefined) {
536
+ throw new Error('invalid usage');
537
+ }
538
+ if (!keyRegex.test(keyOrMatcher.key)) {
539
+ throw new Error(`invalid key "${keyOrMatcher}"`);
540
+ }
541
+ this._matchBuilderVariables.bodyJson.push(keyOrMatcher);
542
+ return this;
543
+ }
544
+
545
+ withBodyGql(gqlMatcher: GQLRequestMatcher): RuleBuilderInitialized {
546
+ this._matchBuilderVariables.bodyGql = gqlMatcher;
547
+ return this;
548
+ }
549
+
550
+ proxyPass(): Stuntman.SerializableRule {
551
+ return this.rule;
552
+ }
553
+
554
+ mockResponse(staticResponse: Stuntman.Response): Stuntman.SerializableRule;
555
+ mockResponse(generationFunction: Stuntman.RemotableFunction<Stuntman.ResponseGenerationFn>): Stuntman.SerializableRule;
556
+ mockResponse(localFn: Stuntman.ResponseGenerationFn, localVariables?: Stuntman.LocalVariables): Stuntman.SerializableRule;
557
+ mockResponse(
558
+ response: Stuntman.Response | Stuntman.RemotableFunction<Stuntman.ResponseGenerationFn> | Stuntman.ResponseGenerationFn,
559
+ localVariables?: Stuntman.LocalVariables
560
+ ): Stuntman.SerializableRule {
561
+ if (typeof response === 'function') {
562
+ this.rule.actions = { mockResponse: { localFn: response, localVariables: localVariables ?? {} } };
563
+ return this.rule;
564
+ }
565
+ if (localVariables) {
566
+ throw new Error('invalid call - localVariables cannot be used together with Response or RemotableFunction');
567
+ }
568
+ this.rule.actions = { mockResponse: response };
569
+ return this.rule;
570
+ }
571
+
572
+ modifyRequest(
573
+ modifyFunction: Stuntman.RequestManipulationFn | Stuntman.RemotableFunction<Stuntman.RequestManipulationFn>,
574
+ localVariables?: Stuntman.LocalVariables
575
+ ): RuleBuilderRequestInitialized {
576
+ if (typeof modifyFunction === 'function') {
577
+ this.rule.actions = { modifyRequest: { localFn: modifyFunction, localVariables: localVariables ?? {} } };
578
+ return new RuleBuilderRequestInitialized(this.rule, this._matchBuilderVariables);
579
+ }
580
+ if (localVariables) {
581
+ throw new Error('invalid call - localVariables cannot be used together with Response or RemotableFunction');
582
+ }
583
+ this.rule.actions = { modifyRequest: modifyFunction };
584
+ return new RuleBuilderRequestInitialized(this.rule, this._matchBuilderVariables);
585
+ }
586
+
587
+ modifyResponse(
588
+ modifyFunction: Stuntman.ResponseManipulationFn | Stuntman.RemotableFunction<Stuntman.ResponseManipulationFn>,
589
+ localVariables?: Stuntman.LocalVariables
590
+ ): Stuntman.SerializableRule {
591
+ if (typeof modifyFunction === 'function') {
592
+ this.rule.actions = { modifyResponse: { localFn: modifyFunction, localVariables: localVariables ?? {} } };
593
+ return this.rule;
594
+ }
595
+ if (localVariables) {
596
+ throw new Error('invalid call - localVariables cannot be used together with Response or RemotableFunction');
597
+ }
598
+ this.rule.actions = { modifyResponse: modifyFunction };
599
+ return this.rule;
600
+ }
601
+ }
602
+
603
+ class RuleBuilderRequestInitialized extends RuleBuilderBase {
604
+ modifyResponse(
605
+ modifyFunction: Stuntman.ResponseManipulationFn | Stuntman.RemotableFunction<Stuntman.ResponseManipulationFn>,
606
+ localVariables?: Stuntman.LocalVariables
607
+ ): Stuntman.SerializableRule {
608
+ if (!this.rule.actions) {
609
+ throw new Error('rule.actions not defined - builder implementation error');
610
+ }
611
+ if (typeof modifyFunction === 'function') {
612
+ this.rule.actions = { modifyResponse: { localFn: modifyFunction, localVariables: localVariables ?? {} } };
613
+ return this.rule;
614
+ }
615
+ if (localVariables) {
616
+ throw new Error('invalid call - localVariables cannot be used together with Response or RemotableFunction');
617
+ }
618
+ this.rule.actions.modifyResponse = modifyFunction;
619
+ return this.rule;
620
+ }
621
+ }
622
+
623
+ export const ruleBuilder = () => new RuleBuilder();