UNPKG

@stuntman/client

Version:

Stuntman - HTTP proxy / mock API client

180 lines (163 loc) 7.72 kB
import serializeJavascript from 'serialize-javascript'; import { ClientError } from './clientError'; import { stuntmanConfig } from '@stuntman/shared'; import type * as Stuntman from '@stuntman/shared'; const SERIALIZE_JAVASCRIPT_OPTIONS: serializeJavascript.SerializeJSOptions = { unsafe: true, ignoreFunction: true, }; const getFunctionParams = (func: () => any) => { const funstr = func.toString(); const params = funstr.slice(funstr.indexOf('(') + 1, funstr.indexOf(')')).match(/([^\s,]+)/g) || new Array<string>(); if (params.includes('=')) { throw new Error('default argument values are not supported'); } return params; }; const serializeApiFunction = (fn: (...args: any[]) => any, variables?: Stuntman.LocalVariables): string => { const variableInitializer: string[] = []; const functionParams = getFunctionParams(fn); if (variables) { for (const varName of Object.keys(variables)) { let varValue = variables[varName]; if (varValue === undefined || varValue === null || typeof varValue === 'number' || typeof varValue === 'boolean') { varValue = `${varValue}`; } else if (typeof varValue === 'string') { varValue = `${serializeJavascript(variables[varName], SERIALIZE_JAVASCRIPT_OPTIONS)}`; } else { varValue = `eval('(${serializeJavascript(variables[varName], SERIALIZE_JAVASCRIPT_OPTIONS).replace( /'/g, "\\'" )})')`; } variableInitializer.push(`const ${varName} = ${varValue};`); } } const functionString = fn.toString(); const serializedHeader = `return ((${functionParams.map((_param, index) => `____arg${index}`).join(',')}) => {`; const serializedParams = `${functionParams .map((_param, index) => `const ${functionParams[index]} = ____arg${index};`) .join('\n')}`; const serializedVariables = `${variableInitializer.join('\n')}`; // prettier-ignore const serializedFunction = `return (${functionString.substring(0, functionString.indexOf('('))}()${functionString.substring(functionString.indexOf(')')+1)})(); })(${functionParams.map((_param, index) => `____arg${index}`).join(',')})`; if (!serializedParams && !serializedVariables) { return `${serializedHeader}${serializedFunction}`; } return [serializedHeader, serializedParams, serializedVariables, serializedFunction].filter((x) => !!x).join('\n'); }; const keysOf = <T extends object>(obj: T): Array<keyof T> => { return Array.from(Object.keys(obj)) as any; }; const serializeRemotableFunctions = <T>(obj: any): Stuntman.WithSerializedFunctions<T> => { const objectKeys = keysOf(obj); if (!objectKeys || objectKeys.length === 0) { return obj; } // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const output: WithSerializedFunctions<T> = {}; for (const key of objectKeys) { if (typeof obj[key] === 'object') { if ('localFn' in obj[key]) { const remotableFunction = obj[key] as Stuntman.RemotableFunction<(...args: any[]) => any>; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore output[key] = { remoteFn: serializeApiFunction(remotableFunction.localFn, remotableFunction.localVariables), localFn: remotableFunction.localFn.toString(), localVariables: serializeJavascript(remotableFunction.localVariables, SERIALIZE_JAVASCRIPT_OPTIONS), }; } else { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore output[key] = serializeRemotableFunctions<any>(obj[key]); } } else { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore output[key] = obj[key]; } } return output; }; export class Client { // TODO websockets connection to API and hooks `onIntereceptedRequest`, `onInterceptedResponse` private options: Stuntman.ClientConfig; private get baseUrl() { return `${this.options.protocol}://${this.options.host}${this.options.port ? `:${this.options.port}` : ''}`; } constructor(options?: Partial<Stuntman.ClientConfig>) { this.options = { ...stuntmanConfig.client, ...options, port: options?.port || (options?.protocol ? (options.protocol === 'https' ? 443 : 80) : stuntmanConfig.client.port), }; } private async fetch(url: RequestInfo, init?: RequestInit): Promise<Response> { const controller = new AbortController(); const timeout = setTimeout(() => { controller.abort(); }, this.options.timeout); try { const response = await fetch(url, { ...init, headers: { ...(this.options.apiKey && { 'x-api-key': this.options.apiKey }), ...init?.headers, }, signal: init?.signal ?? controller.signal, }); if (!response.ok) { const text = await response.text(); let json: any; try { json = JSON.parse(text); } catch { // ignore } if (json && 'error' in json) { throw new ClientError(json.error); } throw new Error(`Unexpected errror: ${text}`); } return response; } finally { clearTimeout(timeout); } } async getRules(): Promise<Stuntman.LiveRule[]> { const response = await this.fetch(`${this.baseUrl}/rules`); return (await response.json()) as Promise<Stuntman.LiveRule[]>; } async getRule(id: string): Promise<Stuntman.LiveRule> { const response = await this.fetch(`${this.baseUrl}/rule/${encodeURIComponent(id)}`); return (await response.json()) as Stuntman.LiveRule; } async disableRule(id: string): Promise<void> { await this.fetch(`${this.baseUrl}/rule/${encodeURIComponent(id)}/disable`); } async enableRule(id: string): Promise<void> { await this.fetch(`${this.baseUrl}/rule/${encodeURIComponent(id)}/enable`); } async removeRule(id: string): Promise<void> { await this.fetch(`${this.baseUrl}/rule/${encodeURIComponent(id)}/remove`); } async addRule(rule: Stuntman.SerializableRule): Promise<Stuntman.Rule> { const serializedRule = serializeRemotableFunctions<Stuntman.SerializableRule>(rule); const response = await this.fetch(`${this.baseUrl}/rule`, { method: 'POST', body: JSON.stringify(serializedRule), headers: { 'content-type': 'application/json' }, }); return (await response.json()) as Stuntman.Rule; } // TODO improve filtering by timestamp from - to, multiple labels, etc. async getTraffic(rule: Stuntman.Rule): Promise<Stuntman.LogEntry[]>; async getTraffic(ruleIdOrLabel: string): Promise<Stuntman.LogEntry[]>; async getTraffic(ruleOrIdOrLabel: string | Stuntman.Rule): Promise<Stuntman.LogEntry[]> { const ruleId = typeof ruleOrIdOrLabel === 'object' ? ruleOrIdOrLabel.id : ruleOrIdOrLabel; const response = await this.fetch(`${this.baseUrl}/traffic${ruleId ? `/${encodeURIComponent(ruleId)}` : ''}`); return (await response.json()) as Stuntman.LogEntry[]; } }