mockttp
Version:
Mock HTTP server for testing HTTP clients and stubbing webservices
452 lines • 21.1 kB
JavaScript
;
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