mockttp
Version:
Mock HTTP server for testing HTTP clients and stubbing webservices
534 lines • 23.8 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.StepDefinitionLookup = exports.WebhookStep = exports.WaitForRequestBodyStep = exports.InformationalResponseStep = 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 util_1 = require("@httptoolkit/util");
const buffer_utils_1 = require("../../util/buffer-utils");
const header_utils_1 = require("../../util/header-utils");
const url_1 = require("../../util/url");
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 {
status;
statusMessage;
data;
headers;
trailers;
type = 'simple';
static isFinal = true;
constructor(status, statusMessage, data, headers, trailers) {
super();
this.status = status;
this.statusMessage = statusMessage;
this.data = data;
this.headers = headers;
this.trailers = trailers;
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;
class CallbackStep extends serialization_1.Serializable {
callback;
type = 'callback';
static isFinal = true;
constructor(callback) {
super();
this.callback = 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;
;
class StreamStep extends serialization_1.Serializable {
status;
stream;
headers;
type = 'stream';
static isFinal = true;
constructor(status, stream, headers) {
super();
this.status = status;
this.stream = stream;
this.headers = headers;
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;
class FileStep extends serialization_1.Serializable {
status;
statusMessage;
filePath;
headers;
type = 'file';
static isFinal = true;
constructor(status, statusMessage, filePath, headers) {
super();
this.status = status;
this.statusMessage = statusMessage;
this.filePath = filePath;
this.headers = headers;
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;
/**
* 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 {
type = 'passthrough';
static isFinal = true;
ignoreHostHttpsErrors = [];
clientCertificateHostMap;
extraCACertificates = [];
transformRequest;
transformResponse;
beforeRequest;
beforeResponse;
proxyConfig;
lookupOptions;
simulateConnectionErrors;
constructor(options = {}) {
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 = (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;
class CloseConnectionStep extends serialization_1.Serializable {
type = 'close-connection';
static isFinal = true;
explain() {
return 'close the connection';
}
}
exports.CloseConnectionStep = CloseConnectionStep;
class ResetConnectionStep extends serialization_1.Serializable {
type = 'reset-connection';
static isFinal = true;
explain() {
return 'reset the connection';
}
}
exports.ResetConnectionStep = ResetConnectionStep;
class TimeoutStep extends serialization_1.Serializable {
type = 'timeout';
static isFinal = true;
explain() {
return 'time out (never respond)';
}
}
exports.TimeoutStep = TimeoutStep;
class JsonRpcResponseStep extends serialization_1.Serializable {
result;
type = 'json-rpc-response';
static isFinal = true;
constructor(result) {
super();
this.result = result;
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;
class DelayStep extends serialization_1.Serializable {
delayMs;
type = 'delay';
static isFinal = false;
constructor(delayMs) {
super();
this.delayMs = delayMs;
}
explain() {
return `wait ${this.delayMs}ms`;
}
}
exports.DelayStep = DelayStep;
/**
* A non-terminal step that sends an HTTP 1xx informational response
* (e.g. 103 Early Hints) before any subsequent step. Multiple
* informational responses may be sent before a final response.
*
* Status must be in 100-199, excluding 101.
*/
class InformationalResponseStep extends serialization_1.Serializable {
status;
headers;
type = 'informational-response';
static isFinal = false;
constructor(status, headers) {
super();
this.status = status;
this.headers = headers;
if (!Number.isInteger(status) || status < 100 || status > 199) {
throw new Error(`Informational response status must be an integer in 100-199 (got ${status})`);
}
if (status === 101) {
throw new Error(`Informational response status 101 is not supported; use websocket rules to handle upgrades`);
}
if (headers !== undefined) {
const pairs = Array.isArray(headers)
? headers.map(([k, v]) => [k, v])
: Object.entries(headers).filter(([, v]) => v !== undefined);
for (const [name, value] of pairs) {
if (!(0, header_utils_1.validateHeader)(name, value)) {
throw new Error(`Invalid informational response header: ${JSON.stringify(name)}`);
}
}
}
}
explain() {
return `send a ${this.status} informational response`;
}
}
exports.InformationalResponseStep = InformationalResponseStep;
class WaitForRequestBodyStep extends serialization_1.Serializable {
type = 'wait-for-request-body';
static isFinal = false;
explain() {
return 'wait for the full request body to be received';
}
}
exports.WaitForRequestBodyStep = WaitForRequestBodyStep;
class WebhookStep extends serialization_1.Serializable {
url;
events;
type = 'webhook';
static isFinal = false;
constructor(url, events) {
super();
this.url = url;
this.events = events;
if (!(0, url_1.isAbsoluteUrl)(url)) {
throw new Error(`Webhook URL "${url}" must be absolute`);
}
}
explain() {
// We actively support sending no events to make it easier to quickly toggle
// settings here during debugging without breaking anything unnecessarily.
return `use ${this.url} as a webhook for ${this.events?.length ? (0, util_1.joinAnd)(this.events) : 'no'} events`;
}
}
exports.WebhookStep = WebhookStep;
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,
'wait-for-request-body': WaitForRequestBodyStep,
'webhook': WebhookStep,
'informational-response': InformationalResponseStep
};
//# sourceMappingURL=request-step-definitions.js.map