UNPKG

mockttp

Version:

Mock HTTP server for testing HTTP clients and stubbing webservices

452 lines 21.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.StepDefinitionLookup = exports.DelayStep = exports.JsonRpcResponseStep = exports.TimeoutStep = exports.ResetConnectionStep = exports.CloseConnectionStep = exports.PassThroughStep = exports.SERIALIZED_OMIT = exports.FileStep = exports.StreamStep = exports.CallbackStep = exports.FixedResponseStep = void 0; const _ = require("lodash"); const base64_arraybuffer_1 = require("base64-arraybuffer"); const stream_1 = require("stream"); const fast_json_patch_1 = require("fast-json-patch"); const buffer_utils_1 = require("../../util/buffer-utils"); const match_replace_1 = require("../match-replace"); const serialization_1 = require("../../serialization/serialization"); const body_serialization_1 = require("../../serialization/body-serialization"); function validateCustomHeaders(originalHeaders, modifiedHeaders, headerWhitelist = []) { 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`); } } class FixedResponseStep extends serialization_1.Serializable { constructor(status, statusMessage, data, headers, trailers) { super(); this.status = status; this.statusMessage = statusMessage; this.data = data; this.headers = headers; this.trailers = trailers; this.type = 'simple'; 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)}` : ""); } } exports.FixedResponseStep = FixedResponseStep; FixedResponseStep.isFinal = true; class CallbackStep extends serialization_1.Serializable { constructor(callback) { super(); this.callback = callback; this.type = 'callback'; } explain() { return 'respond using provided callback' + (this.callback.name ? ` (${this.callback.name})` : ''); } /** * @internal */ serialize(channel) { channel.onRequest(async (streamMsg) => { const request = (0, body_serialization_1.withDeserializedBodyReader)(streamMsg.args[0]); const callbackResult = await this.callback.call(null, request); if (typeof callbackResult === 'string') { return callbackResult; } else { return (0, body_serialization_1.withSerializedCallbackBuffers)(callbackResult); } }); return { type: this.type, name: this.callback.name }; } } exports.CallbackStep = CallbackStep; CallbackStep.isFinal = true; ; class StreamStep extends serialization_1.Serializable { constructor(status, stream, headers) { super(); this.status = status; this.stream = stream; this.headers = headers; this.type = 'stream'; 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) { const serializationStream = new stream_1.Transform({ objectMode: true, transform: function (chunk, _encoding, callback) { let serializedEventData = _.isString(chunk) ? { type: 'string', value: chunk } : _.isBuffer(chunk) ? { type: 'buffer', value: chunk.toString('base64') } : (_.isArrayBuffer(chunk) || _.isTypedArray(chunk)) ? { type: 'arraybuffer', value: (0, base64_arraybuffer_1.encode)(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, { event: 'data', content: serializedEventData }); }, flush: function (callback) { this.push({ 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 }; } } exports.StreamStep = StreamStep; StreamStep.isFinal = true; class FileStep extends serialization_1.Serializable { constructor(status, statusMessage, filePath, headers) { super(); this.status = status; this.statusMessage = statusMessage; this.filePath = filePath; this.headers = headers; this.type = 'file'; 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}` : ""); } } exports.FileStep = FileStep; FileStep.isFinal = true; /** * Used in merging as a marker for values to omit, because lodash ignores undefineds. * @internal */ exports.SERIALIZED_OMIT = "__mockttp__transform__omit__"; class PassThroughStep extends serialization_1.Serializable { constructor(options = {}) { super(); this.type = 'passthrough'; this.ignoreHostHttpsErrors = []; this.extraCACertificates = []; // 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. this.outgoingSockets = new Set(); 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 = (0, fast_json_patch_1.validate)(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 = (0, fast_json_patch_1.validate)(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) { if (this.beforeRequest) { channel.onRequest('beforeRequest', async (req) => { const callbackResult = await this.beforeRequest((0, body_serialization_1.withDeserializedBodyReader)(req.args[0])); const serializedResult = callbackResult ? (0, body_serialization_1.withSerializedCallbackBuffers)(callbackResult) : undefined; if (serializedResult?.response && typeof serializedResult?.response !== 'string') { serializedResult.response = (0, body_serialization_1.withSerializedCallbackBuffers)(serializedResult.response); } return serializedResult; }); } if (this.beforeResponse) { channel.onRequest('beforeResponse', async (req) => { const callbackResult = await this.beforeResponse((0, body_serialization_1.withDeserializedBodyReader)(req.args[0]), (0, body_serialization_1.withDeserializedBodyReader)(req.args[1])); if (typeof callbackResult === 'string') { return callbackResult; } else if (callbackResult) { return (0, body_serialization_1.withSerializedCallbackBuffers)(callbackResult); } else { return undefined; } }); } return { type: this.type, ...this.transformRequest?.replaceHost ? { // Backward compat: forwarding: this.transformRequest?.replaceHost } : {}, proxyConfig: (0, serialization_1.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: (0, serialization_1.serializeBuffer)(pfx), passphrase })), transformRequest: this.transformRequest ? { ...this.transformRequest, // Body is always serialized as a base64 buffer: replaceBody: !!this.transformRequest?.replaceBody ? (0, serialization_1.serializeBuffer)((0, buffer_utils_1.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 ? exports.SERIALIZED_OMIT : v) : undefined, updateJsonBody: !!this.transformRequest?.updateJsonBody ? JSON.stringify(this.transformRequest.updateJsonBody, (k, v) => v === undefined ? exports.SERIALIZED_OMIT : v) : undefined, matchReplaceHost: !!this.transformRequest?.matchReplaceHost ? { ...this.transformRequest.matchReplaceHost, replacements: (0, match_replace_1.serializeMatchReplaceConfiguration)(this.transformRequest.matchReplaceHost.replacements) } : undefined, matchReplacePath: !!this.transformRequest?.matchReplacePath ? (0, match_replace_1.serializeMatchReplaceConfiguration)(this.transformRequest.matchReplacePath) : undefined, matchReplaceQuery: !!this.transformRequest?.matchReplaceQuery ? (0, match_replace_1.serializeMatchReplaceConfiguration)(this.transformRequest.matchReplaceQuery) : undefined, matchReplaceBody: !!this.transformRequest?.matchReplaceBody ? (0, match_replace_1.serializeMatchReplaceConfiguration)(this.transformRequest.matchReplaceBody) : undefined, } : undefined, transformResponse: this.transformResponse ? { ...this.transformResponse, // Body is always serialized as a base64 buffer: replaceBody: !!this.transformResponse?.replaceBody ? (0, serialization_1.serializeBuffer)((0, buffer_utils_1.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 ? exports.SERIALIZED_OMIT : v) : undefined, updateJsonBody: !!this.transformResponse?.updateJsonBody ? JSON.stringify(this.transformResponse.updateJsonBody, (k, v) => v === undefined ? exports.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 }; } } exports.PassThroughStep = PassThroughStep; PassThroughStep.isFinal = true; class CloseConnectionStep extends serialization_1.Serializable { constructor() { super(...arguments); this.type = 'close-connection'; } explain() { return 'close the connection'; } } exports.CloseConnectionStep = CloseConnectionStep; CloseConnectionStep.isFinal = true; class ResetConnectionStep extends serialization_1.Serializable { constructor() { super(...arguments); this.type = 'reset-connection'; } explain() { return 'reset the connection'; } } exports.ResetConnectionStep = ResetConnectionStep; ResetConnectionStep.isFinal = true; class TimeoutStep extends serialization_1.Serializable { constructor() { super(...arguments); this.type = 'timeout'; } explain() { return 'time out (never respond)'; } } exports.TimeoutStep = TimeoutStep; TimeoutStep.isFinal = true; class JsonRpcResponseStep extends serialization_1.Serializable { constructor(result) { super(); this.result = result; this.type = 'json-rpc-response'; 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])}`; } } exports.JsonRpcResponseStep = JsonRpcResponseStep; JsonRpcResponseStep.isFinal = true; class DelayStep extends serialization_1.Serializable { constructor(delayMs) { super(); this.delayMs = delayMs; this.type = 'delay'; } explain() { return `wait ${this.delayMs}ms`; } } exports.DelayStep = DelayStep; DelayStep.isFinal = false; exports.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 }; //# sourceMappingURL=request-step-definitions.js.map