mockttp
Version:
Mock HTTP server for testing HTTP clients and stubbing webservices
675 lines (575 loc) • 26.8 kB
text/typescript
import _ = require('lodash');
import { EventEmitter } from 'events';
import { Duplex } from 'stream';
import DuplexPair = require('native-duplexpair');
import { TypedError } from 'typed-error';
import * as CrossFetch from 'cross-fetch';
import * as WebSocket from 'isomorphic-ws';
import connectWebSocketStream = require('@httptoolkit/websocket-stream');
import { SubscriptionClient } from '@httptoolkit/subscriptions-transport-ws';
import { MaybePromise, getDeferred } from '@httptoolkit/util';
import { print } from 'graphql';
import { DEFAULT_ADMIN_SERVER_PORT } from "../types";
import { RequireProps } from '../util/type-utils';
import { isNode } from '../util/util';
import { delay, isErrorLike } from '@httptoolkit/util';
import { introspectionQuery } from './schema-introspection';
import { MockttpPluginOptions } from '../admin/mockttp-admin-plugin';
import { AdminPlugin, PluginClientResponsesMap, PluginStartParamsMap } from '../admin/admin-plugin-types';
import { SchemaIntrospector } from './schema-introspection';
import { AdminQuery, getSingleSelectedFieldName } from './admin-query';
import { MockttpOptions } from '../mockttp';
const { fetch, Headers } = isNode || typeof globalThis.fetch === 'undefined'
? CrossFetch
: globalThis;
export class ConnectionError extends TypedError { }
// The Response type requires lib.dom. We include an empty placeholder here to
// avoid the types breaking if you don't have that available. Once day TS will
// fix this: https://github.com/microsoft/TypeScript/issues/31894
declare global {
interface Response {}
}
export class RequestError extends TypedError {
constructor(
message: string,
public response: Response
) {
super(message);
}
}
export class GraphQLError extends RequestError {
constructor(
response: Response,
public errors: Array<{ message: string }>
) {
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
);
}
}
// The various events that the admin client can emit:
export type AdminClientEvent =
| 'starting'
| 'started'
| 'start-failed'
| 'stopping'
| 'stopped'
| 'stream-error'
| 'stream-reconnecting'
| 'stream-reconnected'
| 'stream-reconnect-failed'
| 'subscription-error'
| 'subscription-reconnecting';
export interface AdminClientOptions {
/**
* Should the client print extra debug information?
*/
debug?: boolean;
/**
* The full URL to use to connect to a Mockttp admin server when using a
* remote (or local but browser) client.
*
* When using a local server, this option is ignored.
*/
adminServerUrl?: string;
/**
* If the admin stream disconnects, how many times should we try to
* reconnect? Increasing this can be useful in unstable environments, such
* as desktop app use case, while fewer retries will provide faster shutdown
* in environments where you may be killing processes intentionally.
*/
adminStreamReconnectAttempts?: number;
/**
* Options to include on all client requests.
*/
requestOptions?: {
headers?: { [key: string]: string };
};
}
const mergeClientOptions = (
options: RequestInit | undefined,
defaultOptions: AdminClientOptions['requestOptions']
) => {
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 as Headers).append(key, value);
});
} else if (_.isObject(options.headers)) {
Object.assign(options.headers, defaultOptions.headers);
}
}
return options;
};
async function requestFromAdminServer<T>(serverUrl: string, path: string, options?: RequestInit): Promise<T> {
const url = `${serverUrl}${path}`;
let response;
try {
response = await fetch(url, options);
} catch (e) {
if (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: { error?: string } | null = 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.
*/
export async function resetAdminServer(options: AdminClientOptions = {}): Promise<void> {
const serverUrl = options.adminServerUrl ||
`http://localhost:${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.
*/
export class AdminClient<Plugins extends { [key: string]: AdminPlugin<any, any> }> extends EventEmitter {
private adminClientOptions: RequireProps<AdminClientOptions,
'adminServerUrl' | 'adminStreamReconnectAttempts'
>;
private adminSessionBaseUrl: string | undefined;
private adminServerStream: Duplex | undefined;
private subscriptionClient: SubscriptionClient | undefined;
// Metadata from the last start() call, if the server is currently connected:
private adminServerSchema: SchemaIntrospector | undefined;
private adminServerMetadata: PluginClientResponsesMap<Plugins> | undefined;
private debug: boolean = 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.
private running: MaybePromise<boolean> = false;
constructor(options: AdminClientOptions = {}) {
super();
this.debug = !!options.debug;
this.adminClientOptions = _.defaults(options, {
adminServerUrl: `http://localhost:${DEFAULT_ADMIN_SERVER_PORT}`,
adminStreamReconnectAttempts: 5
});
}
private attachStreamWebsocket(adminSessionBaseUrl: string, targetStream: Duplex): Duplex {
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', () => { // Client has shutdown
// 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.
*/
private async tryToReconnectStream(
adminSessionBaseUrl: string,
targetStream: Duplex,
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 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');
});
}
private openStreamToMockServer(adminSessionBaseUrl: string): Promise<Duplex> {
// 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);
});
}
private prepareSubscriptionClientToAdminServer(adminSessionBaseUrl: string) {
const adminSessionBaseWSUrl = adminSessionBaseUrl.replace(/^http/, 'ws');
const subscriptionUrl = `${adminSessionBaseWSUrl}/subscription`;
this.subscriptionClient = new 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')
});
}
private async requestFromMockServer(path: string, options?: RequestInit): Promise<Response> {
// 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;
}
}
private async queryMockServer<T>(query: string, variables?: {}): Promise<T> {
try {
const response = (await this.requestFromMockServer('/', {
method: 'POST',
headers: new Headers({
'Content-Type': 'application/json'
}),
body: JSON.stringify({ query, variables })
}));
const { data, errors }: { data?: T, errors?: Error[] } = await response.json();
if (errors && errors.length) {
throw new GraphQLError(response, errors);
} else {
return data as T;
}
} catch (e) {
if (this.debug) console.error(`Remote client query error: ${e}`);
if (!(e instanceof RequestError)) throw e;
let graphQLErrors: Error[] | undefined = undefined;
try {
graphQLErrors = (await e.response.json()).errors;
} catch (e2) {}
if (graphQLErrors) {
throw new GraphQLError(e.response, graphQLErrors);
} else {
throw e;
}
}
}
async start(
pluginStartParams: PluginStartParamsMap<Plugins>
): Promise<PluginClientResponsesMap<Plugins>> {
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 = getDeferred<boolean>();
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'] as MockttpPluginOptions).port
: undefined;
const path = portConfig ? `/start?port=${JSON.stringify(portConfig)}` : '/start';
const adminServerResponse = await requestFromAdminServer<
{ id: string, pluginData: PluginClientResponsesMap<Plugins> } // New plugin-aware servers
>(
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 SchemaIntrospector(
(await this.queryMockServer<any>(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(): Promise<void> {
if (await this.running === false) return; // If stopped or stopping, do nothing.
this.emit('stopping');
const stopPromise = getDeferred<boolean>();
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);
}
}
private 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;
});
}
public enableDebug = async (): Promise<void> => {
this.debug = true;
return (await this.queryMockServer<void>(
`mutation EnableDebug {
enableDebug
}`
));
}
public reset = async (): Promise<void> => {
return (await this.queryMockServer<void>(
`mutation Reset {
reset
}`
));
}
public async sendQuery<Response, Result = Response>(query: AdminQuery<Response, Result>): Promise<Result> {
return (await this.sendQueries(query))[0];
}
public async sendQueries<Queries extends Array<AdminQuery<any>>>(
...queries: [...Queries]
): Promise<{ [n in keyof Queries]: Queries[n] extends AdminQuery<any, infer R> ? R : never }> {
const results = queries.map<Promise<Array<unknown>>>(
async ({ query, variables, transformResponse }) => {
const result = await this.queryMockServer(print(query), variables);
return transformResponse
? transformResponse(result, { adminClient: this })
: result;
}
);
return Promise.all(results) as Promise<{
[n in keyof Queries]: Queries[n] extends AdminQuery<any, infer R> ? R : never
}>;
}
public async subscribe<Response, Result = Response>(
query: AdminQuery<Response, Result>,
callback: (data: Result) => void
): Promise<void> {
if (await this.running === false) throw new Error('Not connected to mock server');
const fieldName = 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 as Result;
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.
*/
public 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<{
ruleParameterKeys: string[]
}>(
`query GetRuleParameterNames {
ruleParameterKeys
}`
);
return result.ruleParameterKeys;
}
}