UNPKG

mockttp

Version:

Mock HTTP server for testing HTTP clients and stubbing webservices

375 lines (322 loc) 12.2 kB
import { Buffer } from 'buffer'; import { Duplex } from 'stream'; import * as _ from 'lodash'; import { MaybePromise } from '@httptoolkit/util'; import { dereferenceParam, isParamReference, MOCKTTP_PARAM_REF, RuleParameterReference, RuleParameters } from '../rules/rule-parameters'; import type { ProxySetting, ProxySettingSource, ProxySettingCallbackParams, ProxyConfig } from '../rules/proxy-config'; export function serialize<T extends Serializable>( obj: T, stream: Duplex ): SerializedValue<T> { const channel = new ClientServerChannel(stream); const data = obj.serialize(channel) as SerializedValue<T>; data.topicId = channel.topicId; return data; } export function deserialize< Options, T extends SerializedValue<Serializable>, C extends { new(...args: any): any; deserialize(data: SerializedValue<any>, channel: ClientServerChannel, options: Options): any; } >( data: T, stream: Duplex, options: Options, lookup: { [key: string]: C } ): InstanceType<C> { const type = <keyof typeof lookup> data.type; const channel = new ClientServerChannel(stream, data.topicId); const deserialized = lookup[type].deserialize(data, channel, options); // Wrap .dispose and ensure the channel is always disposed too. const builtinDispose = deserialized.dispose; deserialized.dispose = () => { builtinDispose(); channel.dispose(); }; return deserialized; } export type SerializedValue<T> = T & { topicId: string }; // Serialized data = data + type + topicId on every prop/prop's array elements export type Serialized<T> = { [K in keyof T]: T[K] extends string | undefined ? string | undefined : T[K] extends Array<unknown> ? Array<SerializedValue<T[K][0]>> : SerializedValue<T[K]>; }; export abstract class Serializable { abstract type: string; /** * @internal */ serialize(_channel: ClientServerChannel): unknown { // By default, we assume data is transferrable as-is return this; } /** * @internal */ static deserialize( data: SerializedValue<any>, _channel: ClientServerChannel, _options: unknown // Varies, e.g. in plugins. ): any { // By default, we assume we just need to assign the right prototype return _.create(this.prototype, data); } // This rule is being unregistered. Any handlers who need to cleanup when they know // they're no longer in use should implement this and dispose accordingly. // Only deserialized rules are disposed - if the originating rule needs // disposing too, ping the channel and let it know. dispose(): void { } } interface Message { topicId?: string; } interface RequestMessage<R> { requestId?: string; action?: string; error?: Error; data?: R; } const DISPOSE_MESSAGE = { disposeChannel: true }; // Wraps another stream, ensuring that messages go only to the paired channel on the // other client/server. In practice, each handler gets one end of these streams in // their serialize/deserialize methods, and can use them to sync live data reliably. export class ClientServerChannel extends Duplex { public readonly topicId: string; constructor( private rawStream: Duplex, topicId?: string ) { super({ objectMode: true }); this.topicId = topicId || crypto.randomUUID(); this.rawStream.on('error', this._onRawStreamError); this.rawStream.on('finish', this._onRawStreamFinish); } private _onRawStreamError = (error: Error) => { this.destroy(error); }; private _onRawStreamFinish = () => { this.end(); } /** * @internal @hidden */ _write(message: Message, encoding: BufferEncoding, callback: (error?: Error | null) => void) { message.topicId = this.topicId; const chunk = JSON.stringify(message) + '\n'; if (!this.rawStream.write(chunk, encoding)) { this.rawStream.once('drain', callback); } else { callback(); } } _readFromRawStream = (rawData: any) => { const stringData: string = rawData.toString(); stringData.split('\n').filter(d => !!d).forEach((rawDataLine) => { let data: Message; try { data = JSON.parse(rawDataLine); } catch (e) { console.log(e); console.log('Received unparseable message, dropping.', rawDataLine.toString()); return; } if (data.topicId === this.topicId) { if (_.isEqual(_.omit(data, 'topicId'), DISPOSE_MESSAGE)) this.dispose(true); else this.push(data); } }); } private reading = false; _read() { if (!this.reading) { this.rawStream.on('data', this._readFromRawStream); this.reading = true; } } request<T extends {}, R>(data: T): Promise<R>; request<T extends {}, R>(action: string, data: T): Promise<R>; request<T extends {}, R>(actionOrData: string | T, dataOrNothing?: T): Promise<R> { let action: string | undefined; let data: T; if (_.isString(actionOrData)) { action = actionOrData; data = dataOrNothing!; } else { data = actionOrData; } const requestId = crypto.randomUUID(); return new Promise<R>((resolve, reject) => { const responseListener = (response: RequestMessage<R>) => { if (response.requestId === requestId) { if (response.error) { // Derialize error from plain object reject(Object.assign(new Error(), { stack: undefined }, response.error)); } else { resolve(response.data!); } this.removeListener('data', responseListener); } } const request: RequestMessage<T> = { data, requestId }; if (action) request.action = action; this.write(request, (e) => { if (e) reject(e); else this.on('data', responseListener); }); }); } // Wait for requests from the other side, and respond to them onRequest<T, R>(cb: (request: T) => MaybePromise<R>): void; // Wait for requests with a specific { action: actionName } property, and respond onRequest<T, R>( actionName: string, cb: (request: T) => MaybePromise<R> ): void; onRequest<T, R>( cbOrAction: string | ((r: T) => MaybePromise<R>), cbOrNothing?: (request: T) => MaybePromise<R> ): void { let actionName: string | undefined; let cb: (request: T) => MaybePromise<R>; if (_.isString(cbOrAction)) { actionName = cbOrAction; cb = cbOrNothing!; } else { cb = cbOrAction; } this.on('data', async (request: RequestMessage<T>) => { const { requestId, action } = request; // Filter by actionName, if set if (actionName !== undefined && action !== actionName) return; try { const response = { requestId, data: await cb(request.data!) }; if (!this.writable) return; // Response too slow - drop it this.write(response); } catch (error) { // Make the error serializable: error = _.pick(error, Object.getOwnPropertyNames(error)); if (!this.writable) return; // Response too slow - drop it this.write({ requestId, error }); } }); } // Shuts down the channel. Only needs to be called on one side, the other side // will be shut down automatically when it receives DISPOSE_MESSAGE. dispose(disposeReceived: boolean = false) { this.on('error', () => {}); // Dispose is best effort - we don't care about errors // Only one side needs to send a dispose - we send first if we haven't seen one. if (!disposeReceived) this.end(DISPOSE_MESSAGE); else this.end(); // Detach any remaining onRequest handlers: this.removeAllListeners('data'); // Stop receiving upstream messages from the global stream: this.rawStream.removeListener('data', this._readFromRawStream); this.rawStream.removeListener('error', this._onRawStreamError); this.rawStream.removeListener('finish', this._onRawStreamFinish); } } export function serializeBuffer(buffer: Buffer): string { return buffer.toString('base64'); } export function deserializeBuffer(buffer: string): Buffer { return Buffer.from(buffer, 'base64'); } const SERIALIZED_PARAM_REFERENCE = "__mockttp__param__reference__"; export type SerializedRuleParameterReference<R> = { [SERIALIZED_PARAM_REFERENCE]: string }; function serializeParam<R>(value: RuleParameterReference<R>): SerializedRuleParameterReference<R> { // Swap the symbol for a string, since we can't serialize symbols in JSON: return { [SERIALIZED_PARAM_REFERENCE]: value[MOCKTTP_PARAM_REF] }; } function isSerializedRuleParam(value: any): value is SerializedRuleParameterReference<unknown> { return value && SERIALIZED_PARAM_REFERENCE in value; } export function ensureParamsDeferenced<T>( value: T | SerializedRuleParameterReference<T>, ruleParams: RuleParameters ): T { if (isSerializedRuleParam(value)) { const paramRef = { [MOCKTTP_PARAM_REF]: value[SERIALIZED_PARAM_REFERENCE] }; return dereferenceParam(paramRef, ruleParams); } else { return value; } } export type SerializedProxyConfig = | ProxySetting | string // Callback id on the serialization channel | undefined | SerializedRuleParameterReference<ProxySettingSource> | Array<SerializedProxyConfig>; export function serializeProxyConfig( proxyConfig: ProxyConfig, channel: ClientServerChannel ): SerializedProxyConfig { if (_.isFunction(proxyConfig)) { const callbackId = `proxyConfig-callback-${crypto.randomUUID()}`; channel.onRequest< ProxySettingCallbackParams, ProxySetting | undefined >(callbackId, proxyConfig); return callbackId; } else if (_.isArray(proxyConfig)) { return proxyConfig.map((config) => serializeProxyConfig(config, channel)); } else if (isParamReference(proxyConfig)) { return serializeParam(proxyConfig); } else if (proxyConfig) { return { ...proxyConfig, trustedCAs: proxyConfig.trustedCAs?.map((caDefinition) => 'cert' in caDefinition ? { cert: caDefinition.cert.toString('utf8') } // Stringify in case of buffers : caDefinition ), additionalTrustedCAs: proxyConfig.additionalTrustedCAs?.map((caDefinition) => 'cert' in caDefinition ? { cert: caDefinition.cert.toString('utf8') } // Stringify in case of buffers : caDefinition ) }; } } export function deserializeProxyConfig( proxyConfig: SerializedProxyConfig, channel: ClientServerChannel, ruleParams: RuleParameters ): ProxySettingSource { if (_.isString(proxyConfig)) { const callbackId = proxyConfig; const proxyConfigCallback = async (options: ProxySettingCallbackParams) => { return await channel.request< ProxySettingCallbackParams, ProxySetting | undefined >(callbackId, options); }; return proxyConfigCallback; } else if (_.isArray(proxyConfig)) { return proxyConfig.map((config) => deserializeProxyConfig(config, channel, ruleParams)); } else { return ensureParamsDeferenced(proxyConfig, ruleParams); } }