"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.HandlerDefinitionLookup = exports.JsonRpcResponseHandlerDefinition = exports.TimeoutHandlerDefinition = exports.ResetConnectionHandlerDefinition = exports.CloseConnectionHandlerDefinition = exports.PassThroughHandlerDefinition = exports.SERIALIZED_OMIT = exports.FileHandlerDefinition = exports.StreamHandlerDefinition = exports.CallbackHandlerDefinition = exports.SimpleHandlerDefinition = void 0;
const _ = require("lodash");
const url = require("url");
const base64_arraybuffer_1 = require("base64-arraybuffer");
const stream_1 = require("stream");
const common_tags_1 = require("common-tags");
const fast_json_patch_1 = require("fast-json-patch");
const request_utils_1 = require("../../util/request-utils");
const buffer_utils_1 = require("../../util/buffer-utils");
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 SimpleHandlerDefinition 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.SimpleHandlerDefinition = SimpleHandlerDefinition;
class CallbackHandlerDefinition 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 = _.isString(streamMsg.args[0].body)
                ? (0, body_serialization_1.withDeserializedBodyReader)(// New format: body serialized as base64
                streamMsg.args[0])
                : {
                    ...streamMsg.args[0],
                    body: (0, request_utils_1.buildBodyReader)(streamMsg.args[0].body.buffer, streamMsg.args[0].headers)
                };
            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, version: 2 };
    }
}
exports.CallbackHandlerDefinition = CallbackHandlerDefinition;
;
class StreamHandlerDefinition 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.StreamHandlerDefinition = StreamHandlerDefinition;
class FileHandlerDefinition 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.FileHandlerDefinition = FileHandlerDefinition;
/**
 * Used in merging as a marker for values to omit, because lodash ignores undefineds.
 * @internal
 */
exports.SERIALIZED_OMIT = "__mockttp__transform__omit__";
class PassThroughHandlerDefinition 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
        // handler built from a definition. In future, we could improve this (compose instead of inheritance
        // to better control handler construction?) but this will do for now.
        this.outgoingSockets = new Set();
        // If a location is provided, and it's not a bare hostname, it must be parseable
        const { forwarding } = options;
        if (forwarding && forwarding.targetHost.includes('/')) {
            const { protocol, hostname, port, path } = url.parse(forwarding.targetHost);
            if (path && path.trim() !== "/") {
                const suggestion = url.format({ protocol, hostname, port }) ||
                    forwarding.targetHost.slice(0, forwarding.targetHost.indexOf('/'));
                throw new Error((0, common_tags_1.stripIndent) `
                    URLs for forwarding cannot include a path, but "${forwarding.targetHost}" does. ${''}Did you mean ${suggestion}?
                `);
            }
        }
        this.forwarding = forwarding;
        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 ||
                options.trustAdditionalCAs ||
                [];
        this.clientCertificateHostMap = options.clientCertificateHostMap || {};
        if (options.beforeRequest && options.transformRequest && !_.isEmpty(options.transformRequest)) {
            throw new Error("BeforeRequest and transformRequest options are mutually exclusive");
        }
        else if (options.beforeRequest) {
            this.beforeRequest = options.beforeRequest;
        }
        else if (options.transformRequest) {
            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("BeforeResponse and transformResponse options 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() {
        return this.forwarding
            ? `forward the request to ${this.forwarding.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]));
                if (typeof callbackResult === 'string') {
                    return callbackResult;
                }
                else if (callbackResult) {
                    return (0, body_serialization_1.withSerializedCallbackBuffers)(callbackResult);
                }
                else {
                    return undefined;
                }
            });
        }
        return {
            type: this.type,
            ...this.forwarding ? {
                forwarding: this.forwarding,
                // Backward compat:
                forwardToLocation: this.forwarding.targetHost
            } : {},
            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,
                matchReplaceBody: !!this.transformRequest?.matchReplaceBody
                    ? this.transformRequest.matchReplaceBody.map(([match, result]) => [
                        _.isRegExp(match)
                            ? { regexSource: match.source, flags: match.flags }
                            : match,
                        result
                    ])
                    : 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]) => [
                        _.isRegExp(match)
                            ? { regexSource: match.source, flags: match.flags }
                            : match,
                        result
                    ])
                    : undefined,
            } : undefined,
            hasBeforeRequestCallback: !!this.beforeRequest,
            hasBeforeResponseCallback: !!this.beforeResponse
        };
    }
}
exports.PassThroughHandlerDefinition = PassThroughHandlerDefinition;
class CloseConnectionHandlerDefinition extends serialization_1.Serializable {
    constructor() {
        super(...arguments);
        this.type = 'close-connection';
    }
    explain() {
        return 'close the connection';
    }
}
exports.CloseConnectionHandlerDefinition = CloseConnectionHandlerDefinition;
class ResetConnectionHandlerDefinition extends serialization_1.Serializable {
    constructor() {
        super(...arguments);
        this.type = 'reset-connection';
    }
    explain() {
        return 'reset the connection';
    }
}
exports.ResetConnectionHandlerDefinition = ResetConnectionHandlerDefinition;
class TimeoutHandlerDefinition extends serialization_1.Serializable {
    constructor() {
        super(...arguments);
        this.type = 'timeout';
    }
    explain() {
        return 'time out (never respond)';
    }
}
exports.TimeoutHandlerDefinition = TimeoutHandlerDefinition;
class JsonRpcResponseHandlerDefinition 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.JsonRpcResponseHandlerDefinition = JsonRpcResponseHandlerDefinition;
exports.HandlerDefinitionLookup = {
    'simple': SimpleHandlerDefinition,
    'callback': CallbackHandlerDefinition,
    'stream': StreamHandlerDefinition,
    'file': FileHandlerDefinition,
    'passthrough': PassThroughHandlerDefinition,
    'close-connection': CloseConnectionHandlerDefinition,
    'reset-connection': ResetConnectionHandlerDefinition,
    'timeout': TimeoutHandlerDefinition,
    'json-rpc-response': JsonRpcResponseHandlerDefinition
};
//# sourceMappingURL=request-handler-definitions.js.map