UNPKG

@stuntman/client

Version:

Stuntman - HTTP proxy / mock API client

663 lines (625 loc) 27.7 kB
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();