UNPKG

mockttp

Version:

Mock HTTP server for testing HTTP clients and stubbing webservices

1,091 lines (950 loc) 40.2 kB
import _ = require('lodash'); import type * as net from 'net'; import { encode as encodeBase64 } from 'base64-arraybuffer'; import { Readable, Transform } from 'stream'; import { Operation as JsonPatchOperation, validate as validateJsonPatch } from 'fast-json-patch'; import { MaybePromise } from '@httptoolkit/util'; import { Headers, Trailers, CompletedRequest, CompletedBody, Explainable, RawHeaders } from "../../types"; import { Replace } from '../../util/type-utils'; import { asBuffer } from '../../util/buffer-utils'; import { MatchReplacePairs, SerializedMatchReplacePairs, serializeMatchReplaceConfiguration } from '../match-replace'; import { Serializable, ClientServerChannel, serializeBuffer, SerializedProxyConfig, serializeProxyConfig } from "../../serialization/serialization"; import { SerializedBody, withDeserializedBodyReader, withSerializedCallbackBuffers } from '../../serialization/body-serialization'; import { ProxyConfig } from '../proxy-config'; import { CADefinition, ForwardingOptions, PassThroughStepConnectionOptions, PassThroughLookupOptions, PassThroughInitialTransforms } from '../passthrough-handling-definitions'; /* This file defines request step *definitions*, which includes everything necessary to define and serialize their behaviour, but doesn't include the actual handling logic (which lives in the Impl classes in ./request-steps instead). This is intended to allow tree-shaking in browser usage or remote clients, importing only the necessary code, with no need to include all the real request-processing and handling code that is only used at HTTP-runtime, so isn't relevant when defining rules. Every RequestStepImpl extends its definition, simply adding a handle() method, which handles requests according to the configuration, and adding a deserialize static method that takes the serialized output from the serialize() methods defined here and creates a working step. */ /** * The definition of a request rule step, which can be passed to Mockttp to define * a rule. * * Implementation of the step is not included in the definition classes, but * instead exists in an *Impl class defined separately and used internally. */ export interface RequestStepDefinition extends Explainable, Serializable { type: keyof typeof StepDefinitionLookup; } export type SerializedBuffer = { type: 'Buffer', data: number[] }; /** * Can be returned from callbacks to override parts of a request. * * All fields are optional, and omitted values will default to the original * request value. */ export interface CallbackRequestResult { /** * A replacement HTTP method, capitalized. */ method?: string; /** * The full URL to send the request to. If set, this will redirect * the request and automatically update the Host header accordingly, * unless you also provide a `headers` value that includes a Host * header, in which case that will take used as-is. */ url?: string; /** * The replacement HTTP headers, as an object of string keys and either * single string or array of string values. */ headers?: Headers; /** * A string or buffer, which replaces the request body if set. This will * be automatically content-encoded to match the Content-Encoding defined * in your request headers. * * If this is set, the Content-Length header will be automatically updated * or added accordingly, if required. * * You should only return one body field: either `body`, `rawBody` or * `json`. */ body?: string | Buffer | Uint8Array; /** * A buffer, which replaces the request body if set, which is sent exactly * as is, and is not automatically encoded. * * If this is set, the Content-Length header will be automatically updated * or added accordingly, if required. * * You should only return one body field: either `body`, `rawBody` or * `json`. */ rawBody?: Buffer | Uint8Array; /** * A JSON value, which will be stringified and send as a JSON-encoded * request body. This will be automatically content-encoded to match * the Content-Encoding defined in your request headers. * * If this is set, the Content-Length header will be automatically updated * or added accordingly, if required. * * You should only return one body field: either `body`, `rawBody` or * `json`. */ json?: any; /** * A response: either a response object defining the fields of a response * or the string 'close' to immediately close the connection. * * See {@link CallbackResponseMessageResult} for the possible fields that can * be set to define the response. * * If set, the request will not be forwarded at all, and this will be used * as the response to immediately return to the client (or for 'close', this * will immediately close the connection to the client). */ response?: CallbackResponseResult; } export type CallbackResponseResult = | CallbackResponseMessageResult | 'close' | 'reset'; /** * Can be returned from callbacks to define parts of a response, or * override parts when given an existing repsonse. * * All fields are optional, and omitted values will default to the original * response value or a default value. */ export interface CallbackResponseMessageResult { /** * The response status code as a number. * * Defaults to 200 if not set. */ statusCode?: number; /** * The response status message, as a string. This is ignored for * HTTP/2 responses. * * Defaults to the default status message for the status code if not set. */ statusMessage?: string; /** * The replacement HTTP headers, as an object of string keys and either * single string or array of string values. * * Defaults to a minimum set of standard required headers if not set. */ headers?: Headers; /** * The replacement HTTP trailers, as an object of string keys and either * single string or array of string values. Note that there are not all * header fields are valid as trailers, and there are other requirements * such as chunked encoding that must be met for trailers to be sent * successfully. */ trailers?: Trailers; /** * A string or buffer, which replaces the response body if set. This will * be automatically encoded to match the Content-Encoding defined in your * response headers. * * If this is set, the Content-Length header will be automatically updated * or added accordingly, if required. * * Defaults to empty. * * You should only return one body field: either `body`, `rawBody` or * `json`. */ body?: string | Buffer | Uint8Array; /** * A buffer, which replaces the response body if set, which is sent exactly * as is, and is not automatically encoded. * * If this is set, the Content-Length header will be automatically updated * or added accordingly, if required. * * You should only return one body field: either `body`, `rawBody` or * `json`. */ rawBody?: Buffer | Uint8Array; /** * A JSON value, which will be stringified and send as a JSON-encoded * request body. This will be automatically content-encoded to match the * Content-Encoding defined in your response headers. * * If this is set, the Content-Length header will be automatically updated * or added accordingly, if required. * * You should only return one body field: either `body`, `rawBody` or * `json`. */ json?: any; } function validateCustomHeaders( originalHeaders: Headers, modifiedHeaders: Headers | undefined, headerWhitelist: readonly string[] = [] ) { if (!modifiedHeaders) return; // We ignore most returned pseudo headers, so we error if you try to manually set them const invalidHeaders = _(modifiedHeaders) .pickBy((value, name) => name.toString().startsWith(':') && // We allow returning a preexisting header value - that's ignored // silently, so that mutating & returning the provided headers is always safe. value !== originalHeaders[name] && // In some cases, specific custom pseudoheaders may be allowed, e.g. requests // can have custom :scheme and :authority headers set. !headerWhitelist.includes(name) ) .keys(); if (invalidHeaders.size() > 0) { throw new Error( `Cannot set custom ${invalidHeaders.join(', ')} pseudoheader values` ); } } export class FixedResponseStep extends Serializable implements RequestStepDefinition { readonly type = 'simple'; static readonly isFinal = true; constructor( public status: number, public statusMessage?: string, public data?: string | Uint8Array | Buffer | SerializedBuffer, public headers?: Headers, public trailers?: Trailers ) { super(); validateCustomHeaders({}, headers); validateCustomHeaders({}, trailers); if (!_.isEmpty(trailers) && headers) { if (!Object.entries(headers!).some(([key, value]) => key.toLowerCase() === 'transfer-encoding' && value === 'chunked' )) { throw new Error("Trailers can only be set when using chunked transfer encoding"); } } } explain() { return `respond with status ${this.status}` + (this.statusMessage ? ` (${this.statusMessage})`: "") + (this.headers ? `, headers ${JSON.stringify(this.headers)}` : "") + (this.data ? ` and body "${this.data}"` : "") + (this.trailers ? `then trailers ${JSON.stringify(this.trailers)}` : ""); } } /** * @internal */ export interface SerializedCallbackStepData { type: string; name?: string; } /** * @internal */ export interface CallbackRequestMessage { args: [Replace<CompletedRequest, { body: SerializedBody }>]; } export class CallbackStep extends Serializable implements RequestStepDefinition { readonly type = 'callback'; static readonly isFinal = true; constructor( public callback: (request: CompletedRequest) => MaybePromise<CallbackResponseResult> ) { super(); } explain() { return 'respond using provided callback' + (this.callback.name ? ` (${this.callback.name})` : ''); } /** * @internal */ serialize(channel: ClientServerChannel): SerializedCallbackStepData { channel.onRequest< CallbackRequestMessage, CallbackResponseResult >(async (streamMsg) => { const request = withDeserializedBodyReader(streamMsg.args[0]); const callbackResult = await this.callback.call(null, request); if (typeof callbackResult === 'string') { return callbackResult; } else { return withSerializedCallbackBuffers(callbackResult); } }); return { type: this.type, name: this.callback.name }; } } /** * @internal */ export interface SerializedStreamStepData { type: string; status: number; headers?: Headers; }; interface StreamStepMessage { event: 'data' | 'end' | 'close' | 'error'; content: StreamStepEventMessage; } type StreamStepEventMessage = { type: 'string', value: string } | { type: 'buffer', value: string } | { type: 'arraybuffer', value: string } | { type: 'nil' }; export class StreamStep extends Serializable implements RequestStepDefinition { readonly type = 'stream'; static readonly isFinal = true; constructor( public status: number, public stream: Readable & { done?: true }, public headers?: Headers ) { super(); validateCustomHeaders({}, headers); } explain() { return `respond with status ${this.status}` + (this.headers ? `, headers ${JSON.stringify(this.headers)},` : "") + ' and a stream of response data'; } /** * @internal */ serialize(channel: ClientServerChannel): SerializedStreamStepData { const serializationStream = new Transform({ objectMode: true, transform: function (this: Transform, chunk, _encoding, callback) { let serializedEventData: StreamStepEventMessage | false = _.isString(chunk) ? { type: 'string', value: chunk } : _.isBuffer(chunk) ? { type: 'buffer', value: chunk.toString('base64') } : (_.isArrayBuffer(chunk) || _.isTypedArray(chunk)) ? { type: 'arraybuffer', value: encodeBase64(chunk) } : _.isNil(chunk) && { type: 'nil' }; if (!serializedEventData) { callback(new Error(`Can't serialize streamed value: ${chunk.toString()}. Streaming must output strings, buffers or array buffers`)); } callback(undefined, <StreamStepMessage> { event: 'data', content: serializedEventData }); }, flush: function(this: Transform, callback) { this.push(<StreamStepMessage> { event: 'end' }); callback(); } }); // When we get a ping from the server-side, pipe the real stream to serialize it and send the data across channel.once('data', () => { this.stream.pipe(serializationStream).pipe(channel, { end: false }); }); return { type: this.type, status: this.status, headers: this.headers }; } } export class FileStep extends Serializable implements RequestStepDefinition { readonly type = 'file'; static readonly isFinal = true; constructor( public status: number, public statusMessage: string | undefined, public filePath: string, public headers?: Headers ) { super(); validateCustomHeaders({}, headers); } explain() { return `respond with status ${this.status}` + (this.statusMessage ? ` (${this.statusMessage})`: "") + (this.headers ? `, headers ${JSON.stringify(this.headers)}` : "") + (this.filePath ? ` and body from file ${this.filePath}` : ""); } } // This is different from CompletedResponse because CompletedResponse is a client request to Mockttp // whereas this is a real response from an upstream server that we modify before forwarding. // We aim for a similar shape, but they're not exactly the same. export interface PassThroughResponse { id: string; statusCode: number; statusMessage?: string; headers: Headers; rawHeaders: RawHeaders; body: CompletedBody; } export interface PassThroughStepOptions extends PassThroughStepConnectionOptions { /** * A set of data to automatically transform a request. This includes properties * to support many transformation common use cases. * * For advanced cases, a custom callback using beforeRequest can be used instead. * Using this field however where possible is typically simpler, more declarative, * and can be more performant. The two options are mutually exclusive: you cannot * use both transformRequest and a beforeRequest callback. * * Only one transformation for each target (e.g. method, headers & body) can be * specified. If more than one is specified then an error will be thrown when the * rule is registered. */ transformRequest?: RequestTransform; /** * A set of data to automatically transform a response. This includes properties * to support many transformation common use cases. * * For advanced cases, a custom callback using beforeResponse can be used instead. * Using this field however where possible is typically simpler, more declarative, * and can be more performant. The two options are mutually exclusive: you cannot * use both transformResponse and a beforeResponse callback. * * Only one transformation for each target (status, headers & body) can be * specified. If more than one is specified then an error will be thrown when the * rule is registered. */ transformResponse?: ResponseTransform; /** * A callback that will be passed the full request before it is passed through, * and which returns an object that defines how the the request content should * be transformed before it's passed to the upstream server. * * The callback can return an object to define how the request should be changed. * All fields on the object are optional, and returning undefined is equivalent * to returning an empty object (transforming nothing). * * See {@link CallbackRequestResult} for the possible fields that can be set. */ beforeRequest?: (req: CompletedRequest) => MaybePromise<CallbackRequestResult | void> | void; /** * A callback that will be passed the full response before it is passed through, * and which returns a value that defines how the the response content should * be transformed before it's returned to the client. The callback is also passed * the request that was sent to the server (as a 2nd parameter) for reference. * * The callback can either return an object to define how the response should be * changed, or the strings 'close' or 'reset' to immediately close/reset the * underlying connection. * * All fields on the object are optional, and returning undefined is equivalent * to returning an empty object (transforming nothing). * * See {@link CallbackResponseMessageResult} for the possible fields that can be set. */ beforeResponse?: (res: PassThroughResponse, req: CompletedRequest) => MaybePromise<CallbackResponseResult | void> | void; } export interface RequestTransform extends PassThroughInitialTransforms { /** * Override the request protocol. If replaceHost & matchReplaceHost are not specified * and the URL no explicitly specified port, this will automatically switch to the * appropriate port (e.g. from 80 to 443). */ setProtocol?: 'http' | 'https'; /** * A replacement HTTP method. Case insensitive. */ replaceMethod?: string; /** * A headers object which will be merged with the real request headers to add or * replace values. Headers with undefined values will be removed. */ updateHeaders?: Headers; /** * A headers object which will completely replace the real request headers. */ replaceHeaders?: Headers; /** * A string or buffer that replaces the request body entirely. * * If this is specified, the upstream request will not wait for the original request * body, so this may make responses faster than they would be otherwise given large * request bodies or slow/streaming clients. */ replaceBody?: string | Uint8Array | Buffer; /** * The path to a file, which will be used to replace the request body entirely. The * file will be re-read for each request, so the body will always reflect the latest * file contents. * * If this is specified, the upstream request will not wait for the original request * body, so this may make responses faster than they would be otherwise given large * request bodies or slow/streaming clients. */ replaceBodyFromFile?: string; /** * A JSON object which will be merged with the real request body. Undefined values * will be removed, and other values will be merged directly with the target value * recursively. * * Any requests which are received with an invalid JSON body that match this rule * will fail. */ updateJsonBody?: { [key: string]: any; }; /** * A series of operations to apply to the request body in JSON Patch format (RFC * 6902). * * Any requests which are received with an invalid JSON body that match this rule * will fail. */ patchJsonBody?: Array<JsonPatchOperation>; /** * Perform a series of string match & replace operations on the request body. */ matchReplaceBody?: MatchReplacePairs; } export interface ResponseTransform { /** * A replacement response status code. */ replaceStatus?: number; /** * A headers object which will be merged with the real response headers to add or * replace values. Headers with undefined values will be removed. */ updateHeaders?: Headers; /** * A headers object which will completely replace the real response headers. */ replaceHeaders?: Headers; /** * A string or buffer that replaces the response body entirely. * * If this is specified, the downstream response will not wait for the original response * body, so this may make responses arrive faster than they would be otherwise given large * response bodies or slow/streaming servers. */ replaceBody?: string | Uint8Array | Buffer; /** * The path to a file, which will be used to replace the response body entirely. The * file will be re-read for each response, so the body will always reflect the latest * file contents. * * If this is specified, the downstream response will not wait for the original response * body, so this may make responses arrive faster than they would be otherwise given large * response bodies or slow/streaming servers. */ replaceBodyFromFile?: string; /** * A JSON object which will be merged with the real response body. Undefined values * will be removed, and other values will be merged directly with the target value * recursively. * * Any responses which are received with an invalid JSON body that match this rule * will fail. */ updateJsonBody?: { [key: string]: any; }; /** * A series of operations to apply to the response body in JSON Patch format (RFC * 6902). * * Any responses which are received with an invalid JSON body that match this rule * will fail. */ patchJsonBody?: Array<JsonPatchOperation>; /** * Perform a series of string match & replace operations on the response body. */ matchReplaceBody?: MatchReplacePairs; } /** * @internal */ export interface SerializedPassThroughData { type: 'passthrough'; forwarding?: ForwardingOptions; // API backward compat proxyConfig?: SerializedProxyConfig; ignoreHostCertificateErrors?: string[] | boolean; // Doesn't match option name, backward compat extraCACertificates?: Array<{ cert: string } | { certPath: string }>; clientCertificateHostMap?: { [host: string]: { pfx: string, passphrase?: string } }; lookupOptions?: PassThroughLookupOptions; simulateConnectionErrors?: boolean; transformRequest?: Replace<RequestTransform, { 'replaceBody'?: string, // Serialized as base64 buffer 'updateHeaders'?: string, // // Serialized as a string to preserve undefined values 'updateJsonBody'?: string, // Serialized as a string to preserve undefined values 'matchReplaceHost'?: { replacements: SerializedMatchReplacePairs, updateHostHeader?: boolean | string }, 'matchReplacePath'?: SerializedMatchReplacePairs, 'matchReplaceQuery'?: SerializedMatchReplacePairs, 'matchReplaceBody'?: SerializedMatchReplacePairs }>, transformResponse?: Replace<ResponseTransform, { 'replaceBody'?: string, // Serialized as base64 buffer 'updateHeaders'?: string, // // Serialized as a string to preserve undefined values 'updateJsonBody'?: string, // Serialized as a string to preserve undefined values 'matchReplaceBody'?: SerializedMatchReplacePairs }>, hasBeforeRequestCallback?: boolean; hasBeforeResponseCallback?: boolean; } /** * @internal */ export interface BeforePassthroughRequestRequest { args: [Replace<CompletedRequest, { body: SerializedBody }>]; } /** * @internal */ export interface BeforePassthroughResponseRequest { args: [ Replace<PassThroughResponse, { body: SerializedBody }>, Replace<CompletedRequest, { body: SerializedBody }> ]; } /** * Used in merging as a marker for values to omit, because lodash ignores undefineds. * @internal */ export const SERIALIZED_OMIT = "__mockttp__transform__omit__"; export class PassThroughStep extends Serializable implements RequestStepDefinition { readonly type = 'passthrough'; static readonly isFinal = true; public readonly ignoreHostHttpsErrors: string[] | boolean = []; public readonly clientCertificateHostMap: { [host: string]: { pfx: Buffer, passphrase?: string } }; public readonly extraCACertificates: Array<CADefinition> = []; public readonly transformRequest?: RequestTransform; public readonly transformResponse?: ResponseTransform; public readonly beforeRequest?: (req: CompletedRequest) => MaybePromise<CallbackRequestResult | void> | void; public readonly beforeResponse?: (res: PassThroughResponse, req: CompletedRequest) => MaybePromise<CallbackResponseResult | void> | void; public readonly proxyConfig?: ProxyConfig; public readonly lookupOptions?: PassThroughLookupOptions; public readonly simulateConnectionErrors: boolean; // Used in subclass - awkwardly needs to be initialized here to ensure that its set when using a // step built from a definition. In future, we could improve this (compose instead of inheritance // to better control step construction?) but this will do for now. protected outgoingSockets = new Set<net.Socket>(); constructor(options: PassThroughStepOptions = {}) { super(); this.ignoreHostHttpsErrors = options.ignoreHostHttpsErrors || []; if (!Array.isArray(this.ignoreHostHttpsErrors) && typeof this.ignoreHostHttpsErrors !== 'boolean') { throw new Error("ignoreHostHttpsErrors must be an array or a boolean"); } this.lookupOptions = options.lookupOptions; this.proxyConfig = options.proxyConfig; this.simulateConnectionErrors = !!options.simulateConnectionErrors; this.extraCACertificates = options.additionalTrustedCAs || []; this.clientCertificateHostMap = options.clientCertificateHostMap || {}; if (options.beforeRequest && options.transformRequest && !_.isEmpty(options.transformRequest)) { throw new Error("Request callbacks and fixed transforms are mutually exclusive"); } else if (options.beforeRequest) { this.beforeRequest = options.beforeRequest; } else if (options.transformRequest) { if (options.transformRequest.setProtocol && !['http', 'https'].includes(options.transformRequest.setProtocol)) { throw new Error(`Invalid request protocol "${options.transformRequest.setProtocol}" must be "http" or "https"`); } if ([ options.transformRequest.replaceHost, options.transformRequest.matchReplaceHost ].filter(o => !!o).length > 1) { throw new Error("Only one request host transform can be specified at a time"); } if (options.transformRequest.replaceHost) { const { targetHost } = options.transformRequest.replaceHost; if (targetHost.includes('/')) { throw new Error(`Request transform replacement hosts cannot include a path or protocol, but "${targetHost}" does`); } } if (options.transformRequest.matchReplaceHost) { const values = Object.values(options.transformRequest.matchReplaceHost.replacements); for (let replacementValue of values) { if (replacementValue.includes('/')) { throw new Error(`Request transform replacement hosts cannot include a path or protocol, but "${replacementValue}" does`); } } } if ([ options.transformRequest.updateHeaders, options.transformRequest.replaceHeaders ].filter(o => !!o).length > 1) { throw new Error("Only one request header transform can be specified at a time"); } if ([ options.transformRequest.replaceBody, options.transformRequest.replaceBodyFromFile, options.transformRequest.updateJsonBody, options.transformRequest.patchJsonBody, options.transformRequest.matchReplaceBody ].filter(o => !!o).length > 1) { throw new Error("Only one request body transform can be specified at a time"); } if (options.transformRequest.patchJsonBody) { const validationError = validateJsonPatch(options.transformRequest.patchJsonBody); if (validationError) throw validationError; } this.transformRequest = options.transformRequest; } if (options.beforeResponse && options.transformResponse && !_.isEmpty(options.transformResponse)) { throw new Error("Response callbacks and fixed transforms are mutually exclusive"); } else if (options.beforeResponse) { this.beforeResponse = options.beforeResponse; } else if (options.transformResponse) { if ([ options.transformResponse.updateHeaders, options.transformResponse.replaceHeaders ].filter(o => !!o).length > 1) { throw new Error("Only one response header transform can be specified at a time"); } if ([ options.transformResponse.replaceBody, options.transformResponse.replaceBodyFromFile, options.transformResponse.updateJsonBody, options.transformResponse.patchJsonBody, options.transformResponse.matchReplaceBody ].filter(o => !!o).length > 1) { throw new Error("Only one response body transform can be specified at a time"); } if (options.transformResponse.patchJsonBody) { const validationError = validateJsonPatch(options.transformResponse.patchJsonBody); if (validationError) throw validationError; } this.transformResponse = options.transformResponse; } } explain() { const { targetHost } = this.transformRequest?.replaceHost || {}; return targetHost ? `forward the request to ${targetHost}` : 'pass the request through to the target host'; } /** * @internal */ serialize(channel: ClientServerChannel): SerializedPassThroughData { if (this.beforeRequest) { channel.onRequest< BeforePassthroughRequestRequest, CallbackRequestResult | undefined >('beforeRequest', async (req) => { const callbackResult = await this.beforeRequest!( withDeserializedBodyReader(req.args[0]) ); const serializedResult = callbackResult ? withSerializedCallbackBuffers(callbackResult) : undefined; if (serializedResult?.response && typeof serializedResult?.response !== 'string') { serializedResult.response = withSerializedCallbackBuffers(serializedResult.response); } return serializedResult; }); } if (this.beforeResponse) { channel.onRequest< BeforePassthroughResponseRequest, CallbackResponseResult | undefined >('beforeResponse', async (req) => { const callbackResult = await this.beforeResponse!( withDeserializedBodyReader(req.args[0]), withDeserializedBodyReader(req.args[1]), ); if (typeof callbackResult === 'string') { return callbackResult; } else if (callbackResult) { return withSerializedCallbackBuffers(callbackResult); } else { return undefined; } }); } return { type: this.type, ...this.transformRequest?.replaceHost ? { // Backward compat: forwarding: this.transformRequest?.replaceHost } : {}, proxyConfig: serializeProxyConfig(this.proxyConfig, channel), lookupOptions: this.lookupOptions, simulateConnectionErrors: this.simulateConnectionErrors, ignoreHostCertificateErrors: this.ignoreHostHttpsErrors, extraCACertificates: this.extraCACertificates.map((certObject) => { // We use toString to make sure that buffers always end up as // as UTF-8 string, to avoid serialization issues. Strings are an // easy safe format here, since it's really all just plain-text PEM // under the hood. if ('cert' in certObject) { return { cert: certObject.cert.toString('utf8') } } else { return certObject; } }), clientCertificateHostMap: _.mapValues(this.clientCertificateHostMap, ({ pfx, passphrase }) => ({ pfx: serializeBuffer(pfx), passphrase }) ), transformRequest: this.transformRequest ? { ...this.transformRequest, // Body is always serialized as a base64 buffer: replaceBody: !!this.transformRequest?.replaceBody ? serializeBuffer(asBuffer(this.transformRequest.replaceBody)) : undefined, // Update objects need to capture undefined & null as distict values: updateHeaders: !!this.transformRequest?.updateHeaders ? JSON.stringify( this.transformRequest.updateHeaders, (k, v) => v === undefined ? SERIALIZED_OMIT : v ) : undefined, updateJsonBody: !!this.transformRequest?.updateJsonBody ? JSON.stringify( this.transformRequest.updateJsonBody, (k, v) => v === undefined ? SERIALIZED_OMIT : v ) : undefined, matchReplaceHost: !!this.transformRequest?.matchReplaceHost ? { ...this.transformRequest.matchReplaceHost, replacements: serializeMatchReplaceConfiguration(this.transformRequest.matchReplaceHost.replacements) } : undefined, matchReplacePath: !!this.transformRequest?.matchReplacePath ? serializeMatchReplaceConfiguration(this.transformRequest.matchReplacePath) : undefined, matchReplaceQuery: !!this.transformRequest?.matchReplaceQuery ? serializeMatchReplaceConfiguration(this.transformRequest.matchReplaceQuery) : undefined, matchReplaceBody: !!this.transformRequest?.matchReplaceBody ? serializeMatchReplaceConfiguration(this.transformRequest.matchReplaceBody) : undefined, } : undefined, transformResponse: this.transformResponse ? { ...this.transformResponse, // Body is always serialized as a base64 buffer: replaceBody: !!this.transformResponse?.replaceBody ? serializeBuffer(asBuffer(this.transformResponse.replaceBody)) : undefined, // Update objects need to capture undefined & null as distict values: updateHeaders: !!this.transformResponse?.updateHeaders ? JSON.stringify( this.transformResponse.updateHeaders, (k, v) => v === undefined ? SERIALIZED_OMIT : v ) : undefined, updateJsonBody: !!this.transformResponse?.updateJsonBody ? JSON.stringify( this.transformResponse.updateJsonBody, (k, v) => v === undefined ? SERIALIZED_OMIT : v ) : undefined, matchReplaceBody: !!this.transformResponse?.matchReplaceBody ? this.transformResponse.matchReplaceBody.map(([match, result]) => [ match instanceof RegExp ? { regexSource: match.source, flags: match.flags } : match, result ] ) : undefined, } : undefined, hasBeforeRequestCallback: !!this.beforeRequest, hasBeforeResponseCallback: !!this.beforeResponse }; } } export class CloseConnectionStep extends Serializable implements RequestStepDefinition { readonly type = 'close-connection'; static readonly isFinal = true; explain() { return 'close the connection'; } } export class ResetConnectionStep extends Serializable implements RequestStepDefinition { readonly type = 'reset-connection'; static readonly isFinal = true; explain() { return 'reset the connection'; } } export class TimeoutStep extends Serializable implements RequestStepDefinition { readonly type = 'timeout'; static readonly isFinal = true; explain() { return 'time out (never respond)'; } } export class JsonRpcResponseStep extends Serializable implements RequestStepDefinition { readonly type = 'json-rpc-response'; static readonly isFinal = true; constructor( public readonly result: | { result: any, error?: undefined } | { error: any, result?: undefined } ) { super(); if (!('result' in result) && !('error' in result)) { throw new Error('JSON-RPC response must be either a result or an error'); } } explain() { const resultType = 'result' in this.result ? 'result' : 'error'; return `send a fixed JSON-RPC ${resultType} of ${JSON.stringify(this.result[resultType])}`; } } export class DelayStep extends Serializable implements RequestStepDefinition { readonly type = 'delay'; static readonly isFinal = false; constructor( public readonly delayMs: number ) { super() } explain(): string { return `wait ${this.delayMs}ms`; } } export const StepDefinitionLookup = { 'simple': FixedResponseStep, 'callback': CallbackStep, 'stream': StreamStep, 'file': FileStep, 'passthrough': PassThroughStep, 'close-connection': CloseConnectionStep, 'reset-connection': ResetConnectionStep, 'timeout': TimeoutStep, 'json-rpc-response': JsonRpcResponseStep, 'delay': DelayStep }