UNPKG

mockttp

Version:

Mock HTTP server for testing HTTP clients and stubbing webservices

518 lines 19.5 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.MatcherLookup = exports.CallbackMatcher = exports.CookieMatcher = exports.JsonBodyFlexibleMatcher = exports.JsonBodyMatcher = exports.RegexBodyMatcher = exports.RawBodyIncludesMatcher = exports.RawBodyMatcher = exports.MultipartFormDataMatcher = exports.FormDataMatcher = exports.QueryMatcher = exports.ExactQueryMatcher = exports.HeaderMatcher = exports.RegexUrlMatcher = exports.RegexPathMatcher = exports.FlexiblePathMatcher = exports.PortMatcher = exports.HostnameMatcher = exports.HostMatcher = exports.ProtocolMatcher = exports.MethodMatcher = exports.WildcardMatcher = void 0; exports.matchesAll = matchesAll; exports.explainMatchers = explainMatchers; const buffer_1 = require("buffer"); const url = require("url"); const _ = require("lodash"); const common_tags_1 = require("common-tags"); const multipart = require("parse-multipart-data"); const types_1 = require("../types"); const url_1 = require("../util/url"); const request_utils_1 = require("../util/request-utils"); const serialization_1 = require("../serialization/serialization"); const body_serialization_1 = require("../serialization/body-serialization"); function unescapeRegexp(input) { return input.replace(/\\\//g, '/'); } class WildcardMatcher extends serialization_1.Serializable { constructor() { super(...arguments); this.type = 'wildcard'; } matches() { return true; } explain() { return 'for anything'; } } exports.WildcardMatcher = WildcardMatcher; class MethodMatcher extends serialization_1.Serializable { constructor(method) { super(); this.method = method; this.type = 'method'; } matches(request) { return request.method === types_1.Method[this.method]; } explain() { return `making ${types_1.Method[this.method]}s`; } } exports.MethodMatcher = MethodMatcher; class ProtocolMatcher extends serialization_1.Serializable { constructor(protocol) { super(); this.protocol = protocol; this.type = 'protocol'; if (protocol !== "http" && protocol !== "https" && protocol !== "ws" && protocol !== "wss") { throw new Error("Invalid protocol: protocol can only be 'http', 'https', 'ws' or 'wss'"); } } matches(request) { return request.protocol === this.protocol; } explain() { return `for protocol ${this.protocol}`; } } exports.ProtocolMatcher = ProtocolMatcher; class HostMatcher extends serialization_1.Serializable { constructor(host) { super(); this.host = host; this.type = 'host'; // Validate the hostname. Goal here isn't to catch every bad hostname, but allow // every good hostname, and provide friendly errors for obviously bad hostnames. if (host.includes('/')) { throw new Error("Invalid hostname: hostnames can't contain slashes"); } else if (host.includes('?')) { throw new Error("Invalid hostname: hostnames can't contain query strings"); } else if (!host.match(/^([a-zA-Z0-9\-]+\.)*[a-zA-Z0-9\-]+(:\d+)?$/)) { // Port optional throw new Error("Hostname is invalid"); } } matches(request) { const parsedUrl = new url.URL(request.url); if ((this.host.endsWith(':80') && request.protocol === 'http') || (this.host.endsWith(':443') && request.protocol === 'https')) { // On default ports, our URL normalization erases an explicit port, so that a // :80 here will never match anything. This handles that case: if you send HTTP // traffic on port 80 then the port is blank, but it should match for 'hostname:80'. return parsedUrl.hostname === this.host.split(':')[0] && parsedUrl.port === ''; } else { return parsedUrl.host === this.host; } } explain() { return `for host ${this.host}`; } } exports.HostMatcher = HostMatcher; class HostnameMatcher extends serialization_1.Serializable { constructor(hostname) { super(); this.hostname = hostname; this.type = 'hostname'; // Validate the hostname. Goal here isn't to catch every bad hostname, but allow // every good hostname, and provide friendly errors for obviously bad hostnames. if (hostname.includes('/')) { throw new Error("Invalid hostname: hostnames can't contain slashes"); } else if (hostname.includes('?')) { throw new Error("Invalid hostname: hostnames can't contain query strings"); } else if (!hostname.match(/^([a-zA-Z0-9\-]+\.)*[a-zA-Z0-9\-]+$/)) { // No port throw new Error("Hostname is invalid"); } } matches(request) { return new url.URL(request.url).hostname === this.hostname; } explain() { return `for hostname ${this.hostname}`; } } exports.HostnameMatcher = HostnameMatcher; class PortMatcher extends serialization_1.Serializable { constructor(port) { super(); this.type = 'port'; this.port = port.toString(); } matches(request) { const parsedUrl = new url.URL(request.url); if ((this.port === '80' && request.protocol === 'http') || (this.port === '443' && request.protocol === 'https')) { // The port is erased during our URL preprocessing if it's the default, // so for those cases we have to match that separately: return parsedUrl.port === ''; } else { return new url.URL(request.url).port === this.port; } } explain() { return `for port ${this.port}`; } } exports.PortMatcher = PortMatcher; class FlexiblePathMatcher extends serialization_1.Serializable { constructor(path) { super(); this.path = path; this.type = 'simple-path'; if (!this.path) throw new Error('Invalid URL: URL to match must not be empty'); let { search, query } = url.parse(this.path, true); if (search) { throw new Error((0, common_tags_1.oneLine) ` Tried to match a path that contained a query (${search}). To match query parameters, use .withQuery(${JSON.stringify(query)}) instead, or .withExactQuery('${search}') to match this exact query string. `); } (0, url_1.normalizeUrl)(this.path); // Fail if URL can't be normalized } matches(request) { const expectedUrl = (0, url_1.normalizeUrl)(this.path); const reqUrl = (0, url_1.normalizeUrl)(request.url); // reqUrl is always absolute, expectedUrl can be absolute, relative or protocolless-absolute if ((0, url_1.isRelativeUrl)(expectedUrl)) { // Match the path only, for any host return (0, url_1.getPathFromAbsoluteUrl)(reqUrl) === expectedUrl; } else if ((0, url_1.isAbsoluteUrl)(expectedUrl)) { // Full absolute URL: match everything return reqUrl === expectedUrl; } else { // Absolute URL with no protocol return (0, url_1.getUrlWithoutProtocol)(reqUrl) === expectedUrl; } } explain() { return `for ${this.path}`; } } exports.FlexiblePathMatcher = FlexiblePathMatcher; class RegexPathMatcher extends serialization_1.Serializable { constructor(regex) { super(); this.type = 'regex-path'; this.regexSource = regex.source; this.regexFlags = regex.flags; } matches(request) { const absoluteUrl = (0, url_1.normalizeUrl)(request.url); const urlPath = (0, url_1.getPathFromAbsoluteUrl)(absoluteUrl); // Test the matcher against both the path alone & the full URL const urlMatcher = new RegExp(this.regexSource, this.regexFlags); return urlMatcher.test(absoluteUrl) || urlMatcher.test(urlPath); } explain() { return `matching /${unescapeRegexp(this.regexSource)}/${this.regexFlags ?? ''}`; } } exports.RegexPathMatcher = RegexPathMatcher; class RegexUrlMatcher extends serialization_1.Serializable { constructor(regex) { super(); this.type = 'regex-url'; this.regexSource = regex.source; this.regexFlags = regex.flags; } matches(request) { const absoluteUrl = (0, url_1.normalizeUrl)(request.url); // Test the matcher against the full URL const urlMatcher = new RegExp(this.regexSource, this.regexFlags); return urlMatcher.test(absoluteUrl); } explain() { return `matching URL /${unescapeRegexp(this.regexSource)}/${this.regexFlags ?? ''}`; } } exports.RegexUrlMatcher = RegexUrlMatcher; class HeaderMatcher extends serialization_1.Serializable { constructor(headersInput) { super(); this.type = 'header'; this.headers = _.mapKeys(headersInput, (_value, key) => key.toLowerCase()); } matches(request) { return _.isMatch(request.headers, this.headers); } explain() { return `with headers including ${JSON.stringify(this.headers)}`; } } exports.HeaderMatcher = HeaderMatcher; class ExactQueryMatcher extends serialization_1.Serializable { constructor(query) { super(); this.query = query; this.type = 'exact-query-string'; if (query !== '' && query[0] !== '?') { throw new Error('Exact query matches must start with ?, or be empty'); } } matches(request) { const { search } = url.parse(request.url); return this.query === search || (!search && !this.query); } explain() { return this.query ? `with a query exactly matching \`${this.query}\`` : 'with no query string'; } } exports.ExactQueryMatcher = ExactQueryMatcher; class QueryMatcher extends serialization_1.Serializable { constructor(queryObjectInput) { super(); this.type = 'query'; this.queryObject = _.mapValues(queryObjectInput, (v) => Array.isArray(v) ? v.map(av => av.toString()) : v.toString()); } matches(request) { let { query } = url.parse(request.url, true); return _.isMatch(query, this.queryObject); } explain() { return `with a query including ${JSON.stringify(this.queryObject)}`; } } exports.QueryMatcher = QueryMatcher; class FormDataMatcher extends serialization_1.Serializable { constructor(formData) { super(); this.formData = formData; this.type = 'form-data'; } async matches(request) { const contentType = request.headers['content-type']; return !!contentType && contentType.indexOf("application/x-www-form-urlencoded") !== -1 && _.isMatch(await request.body.asFormData(), this.formData); } explain() { return `with form data including ${JSON.stringify(this.formData)}`; } } exports.FormDataMatcher = FormDataMatcher; class MultipartFormDataMatcher extends serialization_1.Serializable { constructor(matchConditions) { super(); this.matchConditions = matchConditions; this.type = 'multipart-form-data'; } async matches(request) { const contentType = request.headers['content-type']; if (!contentType) return false; if (!contentType.includes("multipart/form-data")) return false; const boundary = contentType.match(/;\s*boundary=(\S+)/); if (!boundary) return false; const parsedBody = multipart.parse(await request.body.asDecodedBuffer(), boundary[1]); return this.matchConditions.every((condition) => { const expectedContent = typeof condition.content === 'string' ? buffer_1.Buffer.from(condition.content, "utf8") : condition.content ? buffer_1.Buffer.from(condition.content) : undefined; return parsedBody.some((part) => (expectedContent?.equals(part.data) || expectedContent === undefined) && (condition.filename === part.filename || condition.filename === undefined) && (condition.name === part.name || condition.name === undefined)); }); } explain() { return `with multipart form data matching ${JSON.stringify(this.matchConditions)}`; } } exports.MultipartFormDataMatcher = MultipartFormDataMatcher; class RawBodyMatcher extends serialization_1.Serializable { constructor(content) { super(); this.content = content; this.type = 'raw-body'; } async matches(request) { return (await request.body.asText()) === this.content; } explain() { return `with body '${this.content}'`; } } exports.RawBodyMatcher = RawBodyMatcher; class RawBodyIncludesMatcher extends serialization_1.Serializable { constructor(content) { super(); this.content = content; this.type = 'raw-body-includes'; } async matches(request) { return (await request.body.asText()).includes(this.content); } explain() { return `with a body including '${this.content}'`; } } exports.RawBodyIncludesMatcher = RawBodyIncludesMatcher; class RegexBodyMatcher extends serialization_1.Serializable { constructor(regex) { super(); this.type = 'raw-body-regexp'; this.regexString = regex.source; } async matches(request) { let bodyMatcher = new RegExp(this.regexString); return bodyMatcher.test(await request.body.asText()); } explain() { return `with a body matching /${unescapeRegexp(this.regexString)}/`; } } exports.RegexBodyMatcher = RegexBodyMatcher; class JsonBodyMatcher extends serialization_1.Serializable { constructor(body) { super(); this.body = body; this.type = 'json-body'; } async matches(request) { const receivedBody = await (request.body.asJson().catch(() => undefined)); if (receivedBody === undefined) return false; else return _.isEqual(receivedBody, this.body); } explain() { return `with a JSON body equivalent to ${JSON.stringify(this.body)}`; } } exports.JsonBodyMatcher = JsonBodyMatcher; class JsonBodyFlexibleMatcher extends serialization_1.Serializable { constructor(body) { super(); this.body = body; this.type = 'json-body-matching'; } async matches(request) { const receivedBody = await (request.body.asJson().catch(() => undefined)); if (receivedBody === undefined) return false; else return _.isMatch(receivedBody, this.body); } explain() { return `with a JSON body including ${JSON.stringify(this.body)}`; } } exports.JsonBodyFlexibleMatcher = JsonBodyFlexibleMatcher; class CookieMatcher extends serialization_1.Serializable { constructor(cookie) { super(); this.cookie = cookie; this.type = 'cookie'; } async matches(request) { if (!request.headers || !request.headers.cookie) { return false; } const cookies = request.headers.cookie.split(';').map(cookie => { const [key, value] = cookie.split('='); return { [key.trim()]: (value || '').trim() }; }); return cookies.some(element => _.isEqual(element, this.cookie)); } explain() { return `with cookies including ${JSON.stringify(this.cookie)}`; } } exports.CookieMatcher = CookieMatcher; class CallbackMatcher extends serialization_1.Serializable { constructor(callback) { super(); this.callback = callback; this.type = 'callback'; } async matches(request) { const completedRequest = await (0, request_utils_1.waitForCompletedRequest)(request); return this.callback(completedRequest); } explain() { return `matches using provided callback${this.callback.name ? ` (${this.callback.name})` : ''}`; } /** * @internal */ serialize(channel) { channel.onRequest(async (streamMsg) => { const request = (0, body_serialization_1.withDeserializedBodyReader)(streamMsg); const callbackResult = await this.callback.call(null, request); return callbackResult; }); return { type: this.type, name: this.callback.name, version: 1 }; } /** * @internal */ static deserialize({ name }, channel, { bodySerializer }) { const rpcCallback = async (request) => { const callbackResult = channel.request(await (0, body_serialization_1.withSerializedBodyReader)(request, bodySerializer)); return callbackResult; }; // Pass across the name from the real callback, for explain() Object.defineProperty(rpcCallback, 'name', { value: name }); // Call the client's callback (via stream), and save a handler on our end for // the response that comes back. return new CallbackMatcher(rpcCallback); } } exports.CallbackMatcher = CallbackMatcher; exports.MatcherLookup = { 'wildcard': WildcardMatcher, 'method': MethodMatcher, 'protocol': ProtocolMatcher, 'host': HostMatcher, 'hostname': HostnameMatcher, 'port': PortMatcher, 'simple-path': FlexiblePathMatcher, 'regex-path': RegexPathMatcher, 'regex-url': RegexUrlMatcher, 'header': HeaderMatcher, 'query': QueryMatcher, 'exact-query-string': ExactQueryMatcher, 'form-data': FormDataMatcher, 'multipart-form-data': MultipartFormDataMatcher, 'raw-body': RawBodyMatcher, 'raw-body-regexp': RegexBodyMatcher, 'raw-body-includes': RawBodyIncludesMatcher, 'json-body': JsonBodyMatcher, 'json-body-matching': JsonBodyFlexibleMatcher, 'cookie': CookieMatcher, 'callback': CallbackMatcher, }; async function matchesAll(req, matchers) { return new Promise((resolve, reject) => { const resultsPromises = matchers.map((matcher) => matcher.matches(req)); resultsPromises.forEach(async (maybePromiseResult) => { try { const result = await maybePromiseResult; if (!result) resolve(false); // Resolve mismatches immediately } catch (e) { reject(e); // Resolve matcher failures immediately } }); // Otherwise resolve as normal: all true matches, exceptions reject. Promise.all(resultsPromises) .then((result) => resolve(_.every(result))) .catch((e) => reject(e)); }); } function explainMatchers(matchers) { if (matchers.length === 1) return matchers[0].explain(); if (matchers.length === 2) { // With just two explanations, you can just combine them return `${matchers[0].explain()} ${matchers[1].explain()}`; } // With 3+, we need to oxford comma separate explanations to make them readable return matchers.slice(0, -1) .map((m) => m.explain()) .join(', ') + ', and ' + matchers.slice(-1)[0].explain(); } //# sourceMappingURL=matchers.js.map