@stuntman/client
Version:
Stuntman - HTTP proxy / mock API client
663 lines (625 loc) • 27.7 kB
text/typescript
import { v4 as uuidv4 } from 'uuid';
import type * as Stuntman from '@stuntman/shared';
import { DEFAULT_RULE_PRIORITY, DEFAULT_RULE_TTL_SECONDS, MAX_RULE_TTL_SECONDS, MIN_RULE_TTL_SECONDS } from '@stuntman/shared';
type KeyValueMatcher = string | RegExp | { key: string; value?: string | RegExp };
type ObjectValueMatcher = string | RegExp | number | boolean | null;
type ObjectKeyValueMatcher = { key: string; value?: ObjectValueMatcher };
type GQLRequestMatcher = {
operationName?: string | RegExp;
variables?: ObjectKeyValueMatcher[];
query?: string | RegExp;
type?: 'query' | 'mutation';
methodName?: Stuntman.HttpMethod | RegExp;
};
type MatchBuilderVariables = {
filter?: string | RegExp;
hostname?: string | RegExp;
pathname?: string | RegExp;
port?: number | string | RegExp;
searchParams?: KeyValueMatcher[];
headers?: KeyValueMatcher[];
bodyText?: string | RegExp | null;
bodyJson?: ObjectKeyValueMatcher[];
bodyGql?: GQLRequestMatcher;
jwt?: ObjectKeyValueMatcher[];
};
// TODO add fluent match on multipart from data
function matchFunction(req: Stuntman.Request): Stuntman.RuleMatchResult {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const localMatchBuilderVariables: MatchBuilderVariables = this?.matchBuilderVariables ?? matchBuilderVariables;
const ___url = new URL(req.url);
const ___headers = req.rawHeaders;
const arrayIndexerRegex = /\[(?<arrayIndex>[0-9]*)\]/i;
const matchObject = (
obj: any,
path: string,
value?: string | RegExp | number | boolean | null,
parentPath?: string
): Exclude<Stuntman.RuleMatchResult, boolean> => {
if (!obj) {
return { result: false, description: `${parentPath} is falsey` };
}
const [rawKey, ...rest] = path.split('.');
const key = (rawKey ?? '').replace(arrayIndexerRegex, '');
const shouldBeArray = rawKey ? arrayIndexerRegex.test(rawKey) : false;
const arrayIndex =
rawKey && (arrayIndexerRegex.exec(rawKey)?.groups?.arrayIndex || '').length > 0
? Number(arrayIndexerRegex.exec(rawKey)?.groups?.arrayIndex)
: Number.NaN;
const actualValue = key ? obj[key] : obj;
const currentPath = `${parentPath ? `${parentPath}.` : ''}${rawKey}`;
if (value === undefined && actualValue === undefined) {
return { result: false, description: `${currentPath}=undefined` };
}
if (rest.length === 0) {
if (
shouldBeArray &&
(!Array.isArray(actualValue) || (Number.isInteger(arrayIndex) && actualValue.length <= Number(arrayIndex)))
) {
return { result: false, description: `${currentPath} empty array` };
}
if (value === undefined) {
const result = shouldBeArray
? !Number.isInteger(arrayIndex) || actualValue.length >= Number(arrayIndex)
: actualValue !== undefined;
return { result, description: `${currentPath} === undefined` };
}
if (!shouldBeArray) {
const result = value instanceof RegExp ? value.test(actualValue) : value === actualValue;
return { result, description: `${currentPath} === "${actualValue}"` };
}
}
if (shouldBeArray) {
if (Number.isInteger(arrayIndex)) {
return matchObject(actualValue[Number(arrayIndex)], rest.join('.'), value, currentPath);
}
const hasArrayMatch = (actualValue as Array<any>).some(
(arrayValue) => matchObject(arrayValue, rest.join('.'), value, currentPath).result
);
return { result: hasArrayMatch, description: `array match ${currentPath}` };
}
if (typeof actualValue !== 'object') {
return { result: false, description: `${currentPath} not an object` };
}
return matchObject(actualValue, rest.join('.'), value, currentPath);
};
const ___matchesValue = (matcher: number | string | RegExp | undefined, value?: string | number): boolean => {
if (matcher === undefined) {
return true;
}
if (typeof matcher !== 'string' && !(matcher instanceof RegExp) && typeof matcher !== 'number') {
throw new Error('invalid matcher');
}
if (typeof matcher === 'string' && matcher !== value) {
return false;
}
if (matcher instanceof RegExp && (typeof value !== 'string' || !matcher.test(value))) {
return false;
}
if (typeof matcher === 'number' && (typeof value !== 'number' || matcher !== value)) {
return false;
}
return true;
};
if (!___matchesValue(localMatchBuilderVariables.filter, req.url)) {
return {
result: false,
description: `url ${req.url} doesn't match ${localMatchBuilderVariables.filter?.toString()}`,
};
}
if (!___matchesValue(localMatchBuilderVariables.hostname, ___url.hostname)) {
return {
result: false,
description: `hostname ${___url.hostname} doesn't match ${localMatchBuilderVariables.hostname?.toString()}`,
};
}
if (!___matchesValue(localMatchBuilderVariables.pathname, ___url.pathname)) {
return {
result: false,
description: `pathname ${___url.pathname} doesn't match ${localMatchBuilderVariables.pathname?.toString()}`,
};
}
if (localMatchBuilderVariables.port) {
const port = ___url.port && ___url.port !== '' ? ___url.port : ___url.protocol === 'https:' ? '443' : '80';
if (
!___matchesValue(
localMatchBuilderVariables.port instanceof RegExp
? localMatchBuilderVariables.port
: `${localMatchBuilderVariables.port}`,
port
)
) {
return {
result: false,
description: `port ${port} doesn't match ${localMatchBuilderVariables.port?.toString()}`,
};
}
}
if (localMatchBuilderVariables.searchParams) {
for (const searchParamMatcher of localMatchBuilderVariables.searchParams) {
if (typeof searchParamMatcher === 'string') {
const result = ___url.searchParams.has(searchParamMatcher);
if (!result) {
return { result, description: `searchParams.has("${searchParamMatcher}")` };
}
continue;
}
if (searchParamMatcher instanceof RegExp) {
const result = Array.from(___url.searchParams.keys()).some((key) => searchParamMatcher.test(key));
if (!result) {
return { result, description: `searchParams.keys() matches ${searchParamMatcher.toString()}` };
}
continue;
}
if (!___url.searchParams.has(searchParamMatcher.key)) {
return { result: false, description: `searchParams.has("${searchParamMatcher.key}")` };
}
if (searchParamMatcher.value) {
const value = ___url.searchParams.get(searchParamMatcher.key);
if (!___matchesValue(searchParamMatcher.value, value as string)) {
return {
result: false,
description: `searchParams.get("${searchParamMatcher.key}") = "${searchParamMatcher.value}"`,
};
}
}
}
}
if (localMatchBuilderVariables.headers) {
for (const headerMatcher of localMatchBuilderVariables.headers) {
if (typeof headerMatcher === 'string') {
const result = ___headers.has(headerMatcher);
if (result) {
continue;
}
return { result: false, description: `headers.has("${headerMatcher}")` };
}
if (headerMatcher instanceof RegExp) {
const result = ___headers.toHeaderPairs().some(([key]) => headerMatcher.test(key));
if (result) {
continue;
}
return { result: false, description: `headers.keys matches ${headerMatcher.toString()}` };
}
if (!___headers.has(headerMatcher.key)) {
return { result: false, description: `headers.has("${headerMatcher.key}")` };
}
if (headerMatcher.value) {
const value = ___headers.get(headerMatcher.key);
if (!___matchesValue(headerMatcher.value, value)) {
return {
result: false,
description: `headerMatcher.get("${headerMatcher.key}") = "${headerMatcher.value}"`,
};
}
}
}
}
if (localMatchBuilderVariables.jwt) {
if (!req.jwt) {
return { result: false, description: `no jwt found on request` };
}
for (const jsonMatcher of Array.isArray(localMatchBuilderVariables.jwt)
? localMatchBuilderVariables.jwt
: [localMatchBuilderVariables.jwt]) {
const matchObjectResult = matchObject(req.jwt, jsonMatcher.key, jsonMatcher.value);
if (!matchObjectResult.result) {
return { result: false, description: `jwt $.${jsonMatcher.key} != "${jsonMatcher.value}"` };
}
}
}
if (localMatchBuilderVariables.bodyText === null && !!req.body) {
return { result: false, description: `empty body` };
}
if (localMatchBuilderVariables.bodyText) {
if (!req.body) {
return { result: false, description: `empty body` };
}
if (localMatchBuilderVariables.bodyText instanceof RegExp) {
if (!___matchesValue(localMatchBuilderVariables.bodyText, req.body)) {
return {
result: false,
description: `body text doesn't match ${localMatchBuilderVariables.bodyText.toString()}`,
};
}
} else if (!req.body.includes(localMatchBuilderVariables.bodyText)) {
return {
result: false,
description: `body text doesn't include "${localMatchBuilderVariables.bodyText}"`,
};
}
}
if (localMatchBuilderVariables.bodyJson) {
let json: any;
try {
json = JSON.parse(req.body);
} catch {
return { result: false, description: `unparseable json` };
}
if (!json) {
return { result: false, description: `empty json object` };
}
for (const jsonMatcher of Array.isArray(localMatchBuilderVariables.bodyJson)
? localMatchBuilderVariables.bodyJson
: [localMatchBuilderVariables.bodyJson]) {
const matchObjectResult = matchObject(json, jsonMatcher.key, jsonMatcher.value);
if (!matchObjectResult.result) {
return { result: false, description: `$.${jsonMatcher.key} != "${jsonMatcher.value}"` };
}
}
}
if (localMatchBuilderVariables.bodyGql) {
if (!req.gqlBody) {
return { result: false, description: `not a gql body` };
}
if (!___matchesValue(localMatchBuilderVariables.bodyGql.methodName, req.gqlBody.methodName)) {
return {
result: false,
description: `methodName "${localMatchBuilderVariables.bodyGql.methodName}" !== "${req.gqlBody.methodName}"`,
};
}
if (!___matchesValue(localMatchBuilderVariables.bodyGql.operationName, req.gqlBody.operationName)) {
return {
result: false,
description: `operationName "${localMatchBuilderVariables.bodyGql.operationName}" !== "${req.gqlBody.operationName}"`,
};
}
if (!___matchesValue(localMatchBuilderVariables.bodyGql.query, req.gqlBody.query)) {
return {
result: false,
description: `query "${localMatchBuilderVariables.bodyGql.query}" !== "${req.gqlBody.query}"`,
};
}
if (!___matchesValue(localMatchBuilderVariables.bodyGql.type, req.gqlBody.type)) {
return {
result: false,
description: `type "${localMatchBuilderVariables.bodyGql.type}" !== "${req.gqlBody.type}"`,
};
}
if (localMatchBuilderVariables.bodyGql.variables) {
for (const jsonMatcher of Array.isArray(localMatchBuilderVariables.bodyGql.variables)
? localMatchBuilderVariables.bodyGql.variables
: [localMatchBuilderVariables.bodyGql.variables]) {
const matchObjectResult = matchObject(req.gqlBody.variables, jsonMatcher.key, jsonMatcher.value);
if (!matchObjectResult.result) {
return {
result: false,
description: `GQL variable ${jsonMatcher.key} != "${jsonMatcher.value}". Detail: ${matchObjectResult.description}`,
};
}
}
}
}
return { result: true, description: 'match' };
}
class RuleBuilderBaseBase {
protected rule: Stuntman.SerializableRule;
protected _matchBuilderVariables: MatchBuilderVariables;
constructor(rule?: Stuntman.SerializableRule, _matchBuilderVariables?: MatchBuilderVariables) {
this._matchBuilderVariables = _matchBuilderVariables || {};
this.rule = rule || {
id: uuidv4(),
ttlSeconds: DEFAULT_RULE_TTL_SECONDS,
priority: DEFAULT_RULE_PRIORITY,
actions: {
mockResponse: { status: 200 },
},
matches: {
localFn: matchFunction,
localVariables: { matchBuilderVariables: this._matchBuilderVariables },
},
};
}
}
class RuleBuilderBase extends RuleBuilderBaseBase {
limitedUse(hitCount: number) {
if (this.rule.removeAfterUse) {
throw new Error(`limit already set at ${this.rule.removeAfterUse}`);
}
if (Number.isNaN(hitCount) || !Number.isFinite(hitCount) || !Number.isInteger(hitCount) || hitCount <= 0) {
throw new Error('Invalid hitCount');
}
this.rule.removeAfterUse = hitCount;
return this;
}
singleUse() {
return this.limitedUse(1);
}
storeTraffic() {
this.rule.storeTraffic = true;
return this;
}
disabled() {
this.rule.isEnabled = false;
}
}
class RuleBuilder extends RuleBuilderBase {
raisePriority(by?: number) {
if (this.rule.priority !== DEFAULT_RULE_PRIORITY) {
throw new Error('you should not alter rule priority more than once');
}
const subtract = by ?? 1;
if (subtract >= DEFAULT_RULE_PRIORITY) {
throw new Error(`Unable to raise priority over the default ${DEFAULT_RULE_PRIORITY}`);
}
this.rule.priority = DEFAULT_RULE_PRIORITY - subtract;
return this;
}
decreasePriority(by?: number) {
if (this.rule.priority !== DEFAULT_RULE_PRIORITY) {
throw new Error('you should not alter rule priority more than once');
}
const add = by ?? 1;
this.rule.priority = DEFAULT_RULE_PRIORITY + add;
return this;
}
customTtl(ttlSeconds: number) {
if (Number.isNaN(ttlSeconds) || !Number.isInteger(ttlSeconds) || !Number.isFinite(ttlSeconds) || ttlSeconds < 0) {
throw new Error('Invalid ttl');
}
if (ttlSeconds < MIN_RULE_TTL_SECONDS || ttlSeconds > MAX_RULE_TTL_SECONDS) {
throw new Error(
`ttl of ${ttlSeconds} seconds is outside range min: ${MIN_RULE_TTL_SECONDS}, max:${MAX_RULE_TTL_SECONDS}`
);
}
this.rule.ttlSeconds = ttlSeconds;
return this;
}
customId(id: string) {
this.rule.id = id;
return this;
}
onRequestTo(filter: string | RegExp): RuleBuilderInitialized {
this._matchBuilderVariables.filter = filter;
return new RuleBuilderInitialized(this.rule, this._matchBuilderVariables);
}
onRequestToHostname(hostname: string | RegExp): RuleBuilderInitialized {
this._matchBuilderVariables.hostname = hostname;
return new RuleBuilderInitialized(this.rule, this._matchBuilderVariables);
}
onRequestToPathname(pathname: string | RegExp): RuleBuilderInitialized {
this._matchBuilderVariables.pathname = pathname;
return new RuleBuilderInitialized(this.rule, this._matchBuilderVariables);
}
onRequestToPort(port: string | number | RegExp): RuleBuilderInitialized {
this._matchBuilderVariables.port = port;
return new RuleBuilderInitialized(this.rule, this._matchBuilderVariables);
}
onAnyRequest(): RuleBuilderInitialized {
return new RuleBuilderInitialized(this.rule, this._matchBuilderVariables);
}
}
class RuleBuilderRequestInitialized extends RuleBuilderBase {
modifyResponse(
modifyFunction: Stuntman.ResponseManipulationFn | Stuntman.RemotableFunction<Stuntman.ResponseManipulationFn>,
localVariables?: Stuntman.LocalVariables
): Stuntman.SerializableRule {
if (!this.rule.actions) {
throw new Error('rule.actions not defined - builder implementation error');
}
if (typeof modifyFunction === 'function') {
this.rule.actions.modifyResponse = { localFn: modifyFunction, localVariables: localVariables ?? {} };
return this.rule;
}
if (localVariables) {
throw new Error('invalid call - localVariables cannot be used together with Response or RemotableFunction');
}
this.rule.actions.modifyResponse = modifyFunction;
return this.rule;
}
}
class RuleBuilderInitialized extends RuleBuilderRequestInitialized {
withHostname(hostname: string | RegExp) {
if (this._matchBuilderVariables.hostname) {
throw new Error('hostname already set');
}
this._matchBuilderVariables.hostname = hostname;
return this;
}
withPathname(pathname: string | RegExp) {
if (this._matchBuilderVariables.pathname) {
throw new Error('pathname already set');
}
this._matchBuilderVariables.pathname = pathname;
return this;
}
withPort(port: number | string | RegExp) {
if (this._matchBuilderVariables.port) {
throw new Error('port already set');
}
this._matchBuilderVariables.port = port;
return this;
}
withSearchParam(key: string | RegExp): RuleBuilderInitialized;
withSearchParam(key: string, value?: string | RegExp): RuleBuilderInitialized;
withSearchParam(key: string | RegExp, value?: string | RegExp): RuleBuilderInitialized {
if (!this._matchBuilderVariables.searchParams) {
this._matchBuilderVariables.searchParams = [];
}
if (!key) {
throw new Error('key cannot be empty');
}
if (!value) {
this._matchBuilderVariables.searchParams.push(key);
return this;
}
if (key instanceof RegExp) {
throw new Error('Unsupported regex param key with value');
}
this._matchBuilderVariables.searchParams.push({ key, value });
return this;
}
withSearchParams(params: KeyValueMatcher[]): RuleBuilderInitialized {
if (!this._matchBuilderVariables.searchParams) {
this._matchBuilderVariables.searchParams = [];
}
for (const param of params) {
if (typeof param === 'string' || param instanceof RegExp) {
this.withSearchParam(param);
} else {
this.withSearchParam(param.key, param.value);
}
}
return this;
}
withHeader(key: string | RegExp): RuleBuilderInitialized;
withHeader(key: string, value?: string | RegExp): RuleBuilderInitialized;
withHeader(key: string | RegExp, value?: string | RegExp): RuleBuilderInitialized {
if (!this._matchBuilderVariables.headers) {
this._matchBuilderVariables.headers = [];
}
if (!key) {
throw new Error('key cannot be empty');
}
if (!value) {
this._matchBuilderVariables.headers.push(key);
return this;
}
if (key instanceof RegExp) {
throw new Error('Unsupported regex param key with value');
}
this._matchBuilderVariables.headers.push({ key, value });
return this;
}
withHeaders(...headers: KeyValueMatcher[]): RuleBuilderInitialized {
if (!this._matchBuilderVariables.headers) {
this._matchBuilderVariables.headers = [];
}
for (const header of headers) {
if (typeof header === 'string' || header instanceof RegExp) {
this.withHeader(header);
} else {
this.withHeader(header.key, header.value);
}
}
return this;
}
withBodyText(includes: string): RuleBuilderInitialized;
withBodyText(matches: RegExp): RuleBuilderInitialized;
withBodyText(includesOrMatches: string | RegExp): RuleBuilderInitialized {
if (this._matchBuilderVariables.bodyText) {
throw new Error('bodyText already set');
}
if (this._matchBuilderVariables.bodyText === null) {
throw new Error('cannot use both withBodyText and withoutBody');
}
this._matchBuilderVariables.bodyText = includesOrMatches;
return this;
}
withoutBody(): RuleBuilderInitialized {
if (this._matchBuilderVariables.bodyText) {
throw new Error('cannot use both withBodyText and withoutBody');
}
this._matchBuilderVariables.bodyText = null;
return this;
}
withJwt(hasKey: string): RuleBuilderInitialized;
withJwt(hasKey: string, withValue: ObjectValueMatcher): RuleBuilderInitialized;
withJwt(matches: ObjectKeyValueMatcher): RuleBuilderInitialized;
withJwt(keyOrMatcher: string | ObjectKeyValueMatcher, withValue?: ObjectValueMatcher): RuleBuilderInitialized {
const keyRegex = /^(?:(?:[a-z0-9_-]+)|(?:\[[0-9]*\]))(?:\.(?:(?:[a-z0-9_-]+)|(?:\[[0-9]*\])))*$/i;
if (!this._matchBuilderVariables.jwt) {
this._matchBuilderVariables.jwt = [];
}
if (typeof keyOrMatcher === 'string') {
if (!keyRegex.test(keyOrMatcher)) {
throw new Error(`invalid key "${keyOrMatcher}"`);
}
if (withValue === undefined) {
this._matchBuilderVariables.jwt.push({ key: keyOrMatcher });
} else {
this._matchBuilderVariables.jwt.push({ key: keyOrMatcher, value: withValue });
}
return this;
}
if (withValue !== undefined) {
throw new Error('invalid usage');
}
if (!keyRegex.test(keyOrMatcher.key)) {
throw new Error(`invalid key "${keyOrMatcher}"`);
}
this._matchBuilderVariables.jwt.push(keyOrMatcher);
return this;
}
withBodyJson(hasKey: string): RuleBuilderInitialized;
withBodyJson(hasKey: string, withValue: ObjectValueMatcher): RuleBuilderInitialized;
withBodyJson(matches: ObjectKeyValueMatcher): RuleBuilderInitialized;
withBodyJson(keyOrMatcher: string | ObjectKeyValueMatcher, withValue?: ObjectValueMatcher): RuleBuilderInitialized {
const keyRegex = /^(?:(?:[a-z0-9_-]+)|(?:\[[0-9]*\]))(?:\.(?:(?:[a-z0-9_-]+)|(?:\[[0-9]*\])))*$/i;
if (!this._matchBuilderVariables.bodyJson) {
this._matchBuilderVariables.bodyJson = [];
}
if (typeof keyOrMatcher === 'string') {
if (!keyRegex.test(keyOrMatcher)) {
throw new Error(`invalid key "${keyOrMatcher}"`);
}
if (withValue === undefined) {
this._matchBuilderVariables.bodyJson.push({ key: keyOrMatcher });
} else {
this._matchBuilderVariables.bodyJson.push({ key: keyOrMatcher, value: withValue });
}
return this;
}
if (withValue !== undefined) {
throw new Error('invalid usage');
}
if (!keyRegex.test(keyOrMatcher.key)) {
throw new Error(`invalid key "${keyOrMatcher}"`);
}
this._matchBuilderVariables.bodyJson.push(keyOrMatcher);
return this;
}
withBodyGql(gqlMatcher: GQLRequestMatcher): RuleBuilderInitialized {
if (this._matchBuilderVariables.bodyGql) {
throw new Error('gqlMatcher already set');
}
this._matchBuilderVariables.bodyGql = gqlMatcher;
return this;
}
proxyPass(): Stuntman.SerializableRule {
this.rule.actions = { proxyPass: true };
return this.rule;
}
mockResponse(staticResponse: Stuntman.Response): Stuntman.SerializableRule;
mockResponse(generationFunction: Stuntman.RemotableFunction<Stuntman.ResponseGenerationFn>): Stuntman.SerializableRule;
mockResponse(localFn: Stuntman.ResponseGenerationFn, localVariables?: Stuntman.LocalVariables): Stuntman.SerializableRule;
mockResponse(
response: Stuntman.Response | Stuntman.RemotableFunction<Stuntman.ResponseGenerationFn> | Stuntman.ResponseGenerationFn,
localVariables?: Stuntman.LocalVariables
): Stuntman.SerializableRule {
if (typeof response === 'function') {
this.rule.actions = { mockResponse: { localFn: response, localVariables: localVariables ?? {} } };
return this.rule;
}
if (localVariables) {
throw new Error('invalid call - localVariables cannot be used together with Response or RemotableFunction');
}
this.rule.actions = { mockResponse: response };
return this.rule;
}
modifyRequest(
modifyFunction: Stuntman.RequestManipulationFn | Stuntman.RemotableFunction<Stuntman.RequestManipulationFn>,
localVariables?: Stuntman.LocalVariables
): RuleBuilderRequestInitialized {
if (typeof modifyFunction === 'function') {
this.rule.actions = { modifyRequest: { localFn: modifyFunction, localVariables: localVariables ?? {} } };
return new RuleBuilderRequestInitialized(this.rule, this._matchBuilderVariables);
}
if (localVariables) {
throw new Error('invalid call - localVariables cannot be used together with Response or RemotableFunction');
}
this.rule.actions = { modifyRequest: modifyFunction };
return new RuleBuilderRequestInitialized(this.rule, this._matchBuilderVariables);
}
override modifyResponse(
modifyFunction: Stuntman.ResponseManipulationFn | Stuntman.RemotableFunction<Stuntman.ResponseManipulationFn>,
localVariables?: Stuntman.LocalVariables
): Stuntman.SerializableRule {
if (!this.rule.actions) {
this.rule.actions = { proxyPass: true };
}
return super.modifyResponse(modifyFunction, localVariables);
}
}
export const ruleBuilder = () => new RuleBuilder();