mockttp
Version:
Mock HTTP server for testing HTTP clients and stubbing webservices
524 lines • 23.6 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AdminClient = exports.GraphQLError = exports.RequestError = exports.ConnectionError = void 0;
exports.resetAdminServer = resetAdminServer;
const _ = require("lodash");
const events_1 = require("events");
const DuplexPair = require("native-duplexpair");
const typed_error_1 = require("typed-error");
const CrossFetch = require("cross-fetch");
const WebSocket = require("isomorphic-ws");
const connectWebSocketStream = require("@httptoolkit/websocket-stream");
const subscriptions_transport_ws_1 = require("@httptoolkit/subscriptions-transport-ws");
const util_1 = require("@httptoolkit/util");
const graphql_1 = require("graphql");
const types_1 = require("../types");
const util_2 = require("../util/util");
const util_3 = require("@httptoolkit/util");
const schema_introspection_1 = require("./schema-introspection");
const schema_introspection_2 = require("./schema-introspection");
const admin_query_1 = require("./admin-query");
const { fetch, Headers } = util_2.isNode || typeof globalThis.fetch === 'undefined'
? CrossFetch
: globalThis;
class ConnectionError extends typed_error_1.TypedError {
}
exports.ConnectionError = ConnectionError;
class RequestError extends typed_error_1.TypedError {
constructor(message, response) {
super(message);
this.response = response;
}
}
exports.RequestError = RequestError;
class GraphQLError extends RequestError {
constructor(response, errors) {
super(errors.length === 0
? `GraphQL request failed with ${response.status} response`
: errors.length === 1
? `GraphQL request failed with: ${errors[0].message}`
: // >1
`GraphQL request failed, with errors:\n${errors.map((e) => e.message).join('\n')}`, response);
this.errors = errors;
}
}
exports.GraphQLError = GraphQLError;
const mergeClientOptions = (options, defaultOptions) => {
if (!defaultOptions)
return options;
if (!options)
return defaultOptions;
if (defaultOptions.headers) {
if (!options.headers) {
options.headers = defaultOptions.headers;
}
else if (options.headers instanceof Headers) {
_.forEach(defaultOptions.headers, (value, key) => {
options.headers.append(key, value);
});
}
else if (_.isObject(options.headers)) {
Object.assign(options.headers, defaultOptions.headers);
}
}
return options;
};
async function requestFromAdminServer(serverUrl, path, options) {
const url = `${serverUrl}${path}`;
let response;
try {
response = await fetch(url, options);
}
catch (e) {
if ((0, util_3.isErrorLike)(e) && e.code === 'ECONNREFUSED') {
throw new ConnectionError(`Failed to connect to admin server at ${serverUrl}`);
}
else
throw e;
}
if (response.status >= 400) {
let body = await response.text();
let jsonBody = null;
try {
jsonBody = JSON.parse(body);
}
catch (e) { }
if (jsonBody?.error) {
throw new RequestError(jsonBody.error, response);
}
else {
throw new RequestError(`Request to ${url} failed, with status ${response.status} and response body: ${body}`, response);
}
}
else {
return response.json();
}
}
/**
* Reset a remote admin server, shutting down all Mockttp servers controlled by that
* admin server. This is equivalent to calling `client.stop()` for all remote
* clients of the target server.
*
* This can be useful in some rare cases, where a client might fail to reliably tear down
* its own server, e.g. in Cypress testing. In this case, it's useful to reset the
* admin server completely remotely without needing access to any previous client
* instances, to ensure all servers from previous test runs have been shut down.
*
* After this is called, behaviour of any previously connected clients is undefined, and
* it's likely that they may throw errors or experience other undefined behaviour. Ensure
* that `client.stop()` has been called on all active clients before calling this method.
*/
async function resetAdminServer(options = {}) {
const serverUrl = options.adminServerUrl ||
`http://localhost:${types_1.DEFAULT_ADMIN_SERVER_PORT}`;
await requestFromAdminServer(serverUrl, '/reset', {
...options.requestOptions,
method: 'POST'
});
}
/**
* A bare admin server client. This is not intended for general use, but can be useful when
* building admin server plugins to mock non-HTTP protocols and other advanced use cases.
*
* For normal usage of Mockttp, you should use `Mockttp.getRemote()` instead, to get a Mockttp
* remote client, which wraps this class with the full Mockttp API for mocking HTTP.
*
* This is part of Mockttp's experimental 'pluggable admin' API. It may change
* unpredictably, even in minor releases.
*/
class AdminClient extends events_1.EventEmitter {
constructor(options = {}) {
super();
this.debug = false;
// True if server is entirely initialized, false if it's entirely shut down, or a promise
// that resolves to one or the other if it's currently changing state.
this.running = false;
this.enableDebug = async () => {
this.debug = true;
return (await this.queryMockServer(`mutation EnableDebug {
enableDebug
}`));
};
this.reset = async () => {
return (await this.queryMockServer(`mutation Reset {
reset
}`));
};
this.debug = !!options.debug;
this.adminClientOptions = _.defaults(options, {
adminServerUrl: `http://localhost:${types_1.DEFAULT_ADMIN_SERVER_PORT}`,
adminStreamReconnectAttempts: 5
});
}
attachStreamWebsocket(adminSessionBaseUrl, targetStream) {
const adminSessionBaseWSUrl = adminSessionBaseUrl.replace(/^http/, 'ws');
const wsStream = connectWebSocketStream(`${adminSessionBaseWSUrl}/stream`, {
headers: this.adminClientOptions.requestOptions?.headers // Only used in Node.js (via WS)
});
let streamConnected = false;
wsStream.on('connect', () => {
streamConnected = true;
targetStream.pipe(wsStream);
wsStream.pipe(targetStream, { end: false });
});
// We ignore errors, but websocket closure eventually results in reconnect or shutdown
wsStream.on('error', (e) => {
if (this.debug)
console.warn('Admin client stream error', e);
this.emit('stream-error', e);
});
// When the websocket closes (after connect, either close frame, error, or socket shutdown):
wsStream.on('ws-close', async (closeEvent) => {
targetStream.unpipe(wsStream);
const serverShutdown = closeEvent.code === 1000;
if (serverShutdown) {
// Clean shutdown implies the server is gone, and we need to shutdown & cleanup.
targetStream.emit('server-shutdown');
}
else if (streamConnected && (await this.running) === true) {
console.warn('Admin client stream unexpectedly disconnected', closeEvent);
if (this.adminClientOptions.adminStreamReconnectAttempts > 0) {
this.tryToReconnectStream(adminSessionBaseUrl, targetStream);
}
else {
// If retries are disabled, shut down immediately:
console.log('Admin client stream reconnect disabled, shutting down');
targetStream.emit('server-shutdown');
}
}
// If never connected successfully, we do nothing.
});
targetStream.on('finish', () => {
// Ignore any further WebSocket events - the websocket stream is no longer useful
wsStream.removeAllListeners('connect');
wsStream.removeAllListeners('ws-close');
wsStream.destroy();
});
return wsStream;
}
/**
* Attempt to recreate a stream after disconnection, up to a limited number of retries. This is
* different to normal connection setup, as it assumes the target stream is otherwise already
* set up and active.
*/
async tryToReconnectStream(adminSessionBaseUrl, targetStream, retries = this.adminClientOptions.adminStreamReconnectAttempts) {
this.emit('stream-reconnecting');
// Unclean shutdown means something has gone wrong somewhere. Try to reconnect.
const newStream = this.attachStreamWebsocket(adminSessionBaseUrl, targetStream);
new Promise((resolve, reject) => {
newStream.once('connect', resolve);
newStream.once('error', reject);
}).then(() => {
// On a successful connect, business resumes as normal.
console.warn('Admin client stream reconnected');
this.emit('stream-reconnected');
}).catch(async (err) => {
if (retries > 0) {
// We delay re-retrying briefly - this helps to handle cases like the computer going
// to sleep (where the server & client pause in parallel, but race to do so).
// The delay increases exponentially with retry attempts (10ms, 50, 250, 1250, 6250)
const retryAttempt = this.adminClientOptions.adminStreamReconnectAttempts - retries;
await (0, util_3.delay)(10 * Math.pow(5, retryAttempt));
return this.tryToReconnectStream(adminSessionBaseUrl, targetStream, retries - 1);
}
// Otherwise, once retries have failed, we give up entirely:
console.warn('Admin client stream reconnection failed, shutting down:', err.message);
if (this.debug)
console.warn(err);
this.emit('stream-reconnect-failed', err);
targetStream.emit('server-shutdown');
});
}
openStreamToMockServer(adminSessionBaseUrl) {
// To allow reconnects, we need to not end the client stream when an individual web socket ends.
// To make that work, we return a separate stream, which isn't directly connected to the websocket
// and doesn't receive WS 'end' events, and then we can swap the WS inputs accordingly.
const { socket1: wsTarget, socket2: exposedStream } = new DuplexPair();
const wsStream = this.attachStreamWebsocket(adminSessionBaseUrl, wsTarget);
wsTarget.on('error', (e) => exposedStream.emit('error', e));
// When the server stream ends, end the target stream, which will automatically end all websockets.
exposedStream.on('finish', () => wsTarget.end());
// Propagate 'server is definitely no longer available' back from the websockets:
wsTarget.on('server-shutdown', () => exposedStream.emit('server-shutdown'));
// These receive a lot of listeners! One channel per matcher, handler & completion checker,
// and each adds listeners for data/error/finish/etc. That's OK, it's not generally a leak,
// but maybe 100 would be a bit suspicious (unless you have 30+ active rules).
exposedStream.setMaxListeners(100);
return new Promise((resolve, reject) => {
wsStream.once('connect', () => resolve(exposedStream));
wsStream.once('error', reject);
});
}
prepareSubscriptionClientToAdminServer(adminSessionBaseUrl) {
const adminSessionBaseWSUrl = adminSessionBaseUrl.replace(/^http/, 'ws');
const subscriptionUrl = `${adminSessionBaseWSUrl}/subscription`;
this.subscriptionClient = new subscriptions_transport_ws_1.SubscriptionClient(subscriptionUrl, {
lazy: true, // Doesn't actually connect until you use subscriptions
reconnect: true,
reconnectionAttempts: 8,
wsOptionArguments: [this.adminClientOptions.requestOptions]
}, WebSocket);
this.subscriptionClient.onError((e) => {
this.emit('subscription-error', e);
if (this.debug)
console.error("Subscription error", e);
});
this.subscriptionClient.onReconnecting(() => {
this.emit('subscription-reconnecting');
console.warn('Reconnecting Mockttp subscription client');
});
}
async requestFromMockServer(path, options) {
// Must check for session URL, not this.running, or we can't send the /stop request during shutdown!
if (!this.adminSessionBaseUrl)
throw new Error('Not connected to mock server');
let url = this.adminSessionBaseUrl + path;
let response = await fetch(url, mergeClientOptions(options, this.adminClientOptions.requestOptions));
if (response.status >= 400) {
if (this.debug)
console.error(`Remote client server request failed with status ${response.status}`);
throw new RequestError(`Request to ${url} failed, with status ${response.status}`, response);
}
else {
return response;
}
}
async queryMockServer(query, variables) {
try {
const response = (await this.requestFromMockServer('/', {
method: 'POST',
headers: new Headers({
'Content-Type': 'application/json'
}),
body: JSON.stringify({ query, variables })
}));
const { data, errors } = await response.json();
if (errors && errors.length) {
throw new GraphQLError(response, errors);
}
else {
return data;
}
}
catch (e) {
if (this.debug)
console.error(`Remote client query error: ${e}`);
if (!(e instanceof RequestError))
throw e;
let graphQLErrors = undefined;
try {
graphQLErrors = (await e.response.json()).errors;
}
catch (e2) { }
if (graphQLErrors) {
throw new GraphQLError(e.response, graphQLErrors);
}
else {
throw e;
}
}
}
async start(pluginStartParams) {
if (this.adminSessionBaseUrl || await this.running)
throw new Error('Server is already started');
if (this.debug)
console.log(`Starting remote mock server`);
this.emit('starting');
const startPromise = (0, util_1.getDeferred)();
this.running = startPromise.then((result) => {
this.emit(result ? 'started' : 'start-failed');
this.running = result;
return result;
});
try {
const supportOldServers = 'http' in pluginStartParams;
const portConfig = supportOldServers
? pluginStartParams['http'].port
: undefined;
const path = portConfig ? `/start?port=${JSON.stringify(portConfig)}` : '/start';
const adminServerResponse = await requestFromAdminServer(this.adminClientOptions.adminServerUrl, path, mergeClientOptions({
method: 'POST',
headers: new Headers({
'Content-Type': 'application/json'
}),
body: JSON.stringify({
plugins: pluginStartParams
})
}, this.adminClientOptions.requestOptions));
const sessionId = adminServerResponse.id;
const adminSessionBaseUrl = `${this.adminClientOptions.adminServerUrl}/session/${sessionId}`;
// Also open a stream connection, for 2-way communication we might need later.
const adminServerStream = await this.openStreamToMockServer(adminSessionBaseUrl);
adminServerStream.on('server-shutdown', () => {
// When the server remotely disconnects the stream, shut down the client iff the client hasn't
// stopped & restarted in the meantime (can happen, since all shutdown is async).
if (this.adminServerStream === adminServerStream) {
console.warn('Client stopping due to admin server shutdown');
this.stop();
}
});
this.adminServerStream = adminServerStream;
// Create a subscription client, preconfigured & ready to connect if on() is called later:
this.prepareSubscriptionClientToAdminServer(adminSessionBaseUrl);
// We don't persist the id or resolve the start promise until everything is set up
this.adminSessionBaseUrl = adminSessionBaseUrl;
// Load the schema on server start, so we can check for feature support
this.adminServerSchema = new schema_introspection_2.SchemaIntrospector((await this.queryMockServer(schema_introspection_1.introspectionQuery)).__schema);
if (this.debug)
console.log('Started remote mock server');
// Set field before we resolve the promise:
const serverMetadata = this.adminServerMetadata = adminServerResponse.pluginData;
startPromise.resolve(true);
return serverMetadata;
}
catch (e) {
startPromise.resolve(false);
throw e;
}
}
isRunning() {
return this.running === true;
}
get metadata() {
if (!this.isRunning())
throw new Error("Metadata is not available until the mock server is started");
return this.adminServerMetadata;
}
get schema() {
if (!this.isRunning())
throw new Error("Admin schema is not available until the mock server is started");
return this.adminServerSchema;
}
get adminStream() {
if (!this.isRunning())
throw new Error("Admin stream is not available until the mock server is started");
return this.adminServerStream;
}
// Call when either we want the server to stop, or it appears that the server has already stopped,
// and we just want to ensure that's happened and clean everything up.
async stop() {
if (await this.running === false)
return; // If stopped or stopping, do nothing.
this.emit('stopping');
const stopPromise = (0, util_1.getDeferred)();
this.running = stopPromise.then((result) => {
this.emit('stopped');
this.running = result;
return result;
});
try {
if (this.debug)
console.log('Stopping remote mock server');
try {
this.subscriptionClient?.close();
}
catch (e) {
console.log(e);
}
this.subscriptionClient = undefined;
try {
this.adminServerStream?.end();
}
catch (e) {
console.log(e);
}
this.adminServerStream = undefined;
await this.requestServerStop();
}
finally {
// The client is always stopped (and so restartable) once stopping completes, in all
// cases, since it can always be started again to reset it. The promise is just here
// so that we successfully handle (and always wait for) parallel stops.
stopPromise.resolve(false);
}
}
requestServerStop() {
return this.requestFromMockServer('/stop', {
method: 'POST'
}).catch((e) => {
if (e instanceof RequestError && e.response.status === 404) {
// 404 means it doesn't exist, generally because it was already stopped
// by some other parallel shutdown process.
return;
}
else {
throw e;
}
}).then(() => {
this.adminSessionBaseUrl = undefined;
this.adminServerSchema = undefined;
this.adminServerMetadata = undefined;
});
}
async sendQuery(query) {
return (await this.sendQueries(query))[0];
}
async sendQueries(...queries) {
const results = queries.map(async ({ query, variables, transformResponse }) => {
const result = await this.queryMockServer((0, graphql_1.print)(query), variables);
return transformResponse
? transformResponse(result, { adminClient: this })
: result;
});
return Promise.all(results);
}
async subscribe(query, callback) {
if (await this.running === false)
throw new Error('Not connected to mock server');
const fieldName = (0, admin_query_1.getSingleSelectedFieldName)(query);
if (!this.schema.typeHasField('Subscription', fieldName)) {
console.warn(`Ignoring client subscription for event unrecognized by Mockttp server: ${fieldName}`);
return Promise.resolve();
}
// This isn't 100% correct (you can be WS-connected, but still negotiating some GQL
// setup) but it's good enough for our purposes (knowing-ish if the connection worked).
let isConnected = !!this.subscriptionClient.client;
this.subscriptionClient.request(query).subscribe({
next: async (value) => {
if (value.data) {
const response = value.data[fieldName];
const result = query.transformResponse
? await query.transformResponse(response, { adminClient: this })
: response;
callback(result);
}
else if (value.errors) {
console.error('Error in subscription', value.errors);
}
},
error: (e) => this.debug && console.warn('Error in remote subscription:', e)
});
return new Promise((resolve, reject) => {
if (isConnected)
resolve();
else {
this.subscriptionClient.onConnected(resolve);
this.subscriptionClient.onDisconnected(reject);
this.subscriptionClient.onError(reject);
}
});
}
/**
* List the names of the rule parameters defined by the admin server. This can be
* used in some advanced use cases to confirm that the parameters a client wishes to
* reference are available.
*
* Only defined for remote clients.
*/
async getRuleParameterKeys() {
if (await this.running === false) {
throw new Error('Cannot query rule parameters before the server is started');
}
if (!this.schema.queryTypeDefined('ruleParameterKeys')) {
// If this endpoint isn't supported, that's because parameters aren't supported
// at all, so we can safely report that immediately.
return [];
}
let result = await this.queryMockServer(`query GetRuleParameterNames {
ruleParameterKeys
}`);
return result.ruleParameterKeys;
}
}
exports.AdminClient = AdminClient;
//# sourceMappingURL=admin-client.js.map