UNPKG

mockttp

Version:

Mock HTTP server for testing HTTP clients and stubbing webservices

581 lines (485 loc) 22.2 kB
import { Buffer } from 'buffer'; import * as http from 'http'; import * as net from 'net'; import * as _ from 'lodash'; import * as express from 'express'; import * as cors from 'cors'; import corsGate = require('cors-gate'); import * as bodyParser from 'body-parser'; import * as Ws from 'ws'; import { createHandler as createGraphQLHandler } from 'graphql-http/lib/use/express'; import { execute, GraphQLScalarType, subscribe } from 'graphql'; import gql from 'graphql-tag'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { SubscriptionServer } from '@httptoolkit/subscriptions-transport-ws'; import { EventEmitter } from 'stream'; import DuplexPair = require('native-duplexpair'); import { makeDestroyable, DestroyableServer } from "destroyable-server"; import { isErrorLike } from '@httptoolkit/util'; import { objectAllPromise } from '../util/promise'; import { DEFAULT_ADMIN_SERVER_PORT } from '../types'; import { RuleParameters } from '../rules/rule-parameters'; import { AdminPlugin, PluginConstructorMap, PluginStartDefaults, PluginStartParamsMap } from './admin-plugin-types'; import { parseAnyAst } from './graphql-utils'; export interface AdminServerOptions<Plugins extends { [key: string]: AdminPlugin<any, any> }> { /** * Should the admin server print extra debug information? This enables admin server debugging * only - individual mock session debugging must be enabled separately. */ debug?: boolean; /** * Set CORS options to limit the sites which can send requests to manage this admin server. */ corsOptions?: cors.CorsOptions & { strict?: boolean, allowPrivateNetworkAccess?: boolean }; /** * Set a keep alive frequency in milliseconds for the subscription & stream websockets of each * session, to ensure they remain connected in long-lived connections, especially in browsers which * often close quiet background connections automatically. */ webSocketKeepAlive?: number; /** * Override the default parameters for sessions started from this admin server. These values will be * used for each setting that is not explicitly specified by the client when creating a mock session. */ pluginDefaults?: PluginStartDefaults<Plugins>; /** * Some rule options can't easily be specified in remote clients, since they need to access * server-side state or Node APIs directly. To handle this, referenceable parameters can * be provided here, and referenced with a `{ [MOCKTTP_PARAM_REF]: <value> }` value in place * of the real parameter in the remote client. */ ruleParameters?: { [key: string]: any } /** * @internal * * This API is not yet stable, and is intended for internal use only. It may change in future * in minor versions without warning. * * This defines admin plugin modules: remote-controlled types of mocks that should be attached to this * admin server, to allow configuring other mocking services through the same HTTP infrastructure. * * This can be useful when mocking non-HTTP protocols like WebRTC. */ adminPlugins?: PluginConstructorMap<Plugins> } async function strictOriginMatch( origin: string | undefined, expectedOrigin: cors.CorsOptions['origin'] ): Promise<boolean> { if (!origin) return false; if (typeof expectedOrigin === 'string') { return expectedOrigin === origin; } if (expectedOrigin instanceof RegExp) { return !!origin.match(expectedOrigin); } if (_.isArray(expectedOrigin)) { return _.some(expectedOrigin, (exp) => strictOriginMatch(origin, exp) ); } if (_.isFunction(expectedOrigin)) { return new Promise<boolean>((resolve, reject) => { expectedOrigin(origin, (error, result) => { if (error) reject(error); else resolve(strictOriginMatch(origin, result)); }); }); } // We don't allow boolean or undefined matches return false; } export class AdminServer<Plugins extends { [key: string]: AdminPlugin<any, any> }> { private debug: boolean; private requiredOrigin: cors.CorsOptions['origin'] | false; private webSocketKeepAlive: number | undefined; private ruleParams: RuleParameters; private app = express(); private server: DestroyableServer<http.Server> | null = null; private eventEmitter = new EventEmitter(); private adminPlugins: PluginConstructorMap<Plugins>; private sessions: { [id: string]: { router: express.Router, stop: () => Promise<void>, subscriptionServer: SubscriptionServer, streamServer: Ws.Server, sessionPlugins: Plugins } } = { }; constructor(options: AdminServerOptions<Plugins> = {}) { this.debug = options.debug || false; if (this.debug) console.log('Admin server started in debug mode'); this.webSocketKeepAlive = options.webSocketKeepAlive || undefined; this.ruleParams = options.ruleParameters || {}; this.adminPlugins = options.adminPlugins || {} as PluginConstructorMap<Plugins>; if (options.corsOptions?.allowPrivateNetworkAccess) { // Allow web pages on non-local URLs (testsite.example.com, not localhost) to // send requests to this admin server too. Without this, those requests will // fail after rejected preflights in recent Chrome (from ~v102, ish? Unclear). // This is combined with the origin restrictions that may be set, so only // accepted origins will be allowed to make these requests. this.app.use((req, res, next) => { if (req.headers["access-control-request-private-network"]) { res.setHeader("access-control-allow-private-network", "true"); } next(null); }); } this.app.use(cors(options.corsOptions)); // If you use strict CORS, and set a specific origin, we'll enforce it: this.requiredOrigin = !!options.corsOptions && !!options.corsOptions.strict && !!options.corsOptions.origin && typeof options.corsOptions.origin !== 'boolean' && options.corsOptions.origin; if (this.requiredOrigin) { this.app.use(corsGate({ strict: true, // MUST send an allowed origin allowSafe: false, // Even for HEAD/GET requests (should be none anyway) origin: '' // No base origin - we accept *no* same-origin requests })); } this.app.use(bodyParser.json({ limit: '50mb' })); const defaultPluginStartParams: PluginStartDefaults<Plugins> = options.pluginDefaults ?? {}; this.app.post('/start', async (req, res) => { try { const rawConfig = req.body; const providedPluginStartParams = rawConfig.plugins as PluginStartParamsMap<Plugins>; // For each plugin that was specified, we pull default params into their start params. const pluginStartParams = _.mapValues((providedPluginStartParams), (params, pluginId) => { return _.merge({}, defaultPluginStartParams[pluginId], params); }); if (this.debug) console.log('Admin server starting mock session with config', pluginStartParams); const missingPluginId = Object.keys(pluginStartParams).find(pluginId => !(pluginId in this.adminPlugins)); if (missingPluginId) { res.status(400).json({ error: `Request to mock using unrecognized plugin: ${missingPluginId}` }); return; } const sessionPlugins = _.mapValues(pluginStartParams, (__, pluginId: keyof Plugins) => { const PluginType = this.adminPlugins[pluginId]; return new PluginType(); }) as Plugins; const pluginStartResults = await objectAllPromise( _.mapValues(sessionPlugins, (plugin, pluginId: keyof Plugins) => plugin.start(pluginStartParams[pluginId]) ) ); const sessionId = crypto.randomUUID(); await this.startSessionManagementAPI(sessionId, sessionPlugins); res.json({ id: sessionId, pluginData: _.mapValues(pluginStartResults, (r: unknown) => r ?? {} // Always return _something_, even if the plugin returns null/undefined. ) }); } catch (e) { res.status(500).json({ error: `Failed to start mock session: ${ (isErrorLike(e) && e.message) || e }` }); } }); this.app.post('/reset', async (req, res) => { try { await this.resetAdminServer(); res.json({ success: true }); } catch (e) { res.status(500).json({ error: (isErrorLike(e) && e.message) || 'Unknown error' }); } }); // Dynamically route to mock sessions ourselves, so we can easily add/remove // sessions as we see fit later on. const sessionRequest = (req: express.Request, res: express.Response, next: express.NextFunction) => { const sessionId = req.params.id; const sessionRouter = this.sessions[sessionId]?.router; if (!sessionRouter) { res.status(404).send('Unknown mock session'); console.error(`Request for unknown mock session with id: ${sessionId}`); return; } sessionRouter(req, res, next); } this.app.use('/session/:id/', sessionRequest); } async resetAdminServer() { if (this.debug) console.log('Resetting admin server'); await Promise.all( Object.values(this.sessions).map(({ stop }) => stop()) ); } /** * Subscribe to hear when each mock ession is started. The listener is provided the * session plugin data, which can be used to log session startup, add side-effects that * run elsewhere at startup, or preconfigure every started plugin in addition ways. * * This is run synchronously when a session is created, after it has fully started * but before its been returned to remote clients. */ on(event: 'mock-session-started', listener: (plugins: Plugins, sessionId: string) => void): void; /** * Subscribe to hear when a mock session is stopped. The listener is provided with * the state of all plugins that are about to be stopped. This can be used to log * mock session shutdown, add side-effects that run elsewhere at shutdown, or clean * up after sessions in other ways. * * This is run synchronously immediately before the session is shutdown, whilst all * its state is still available, and before remote clients have had any response to * their request. This is also run before shutdown when the admin server itself is * cleanly shutdown with `adminServer.stop()`. */ on(event: 'mock-session-stopping', listener: (plugins: Plugins, sessionId: string) => void): void; on(event: string, listener: (...args: any) => void): void { this.eventEmitter.on(event, listener); } async start( listenOptions: number | { port: number, host: string } = DEFAULT_ADMIN_SERVER_PORT ) { if (this.server) throw new Error('Admin server already running'); await new Promise<void>((resolve, reject) => { this.server = makeDestroyable(this.app.listen(listenOptions, resolve)); this.server.on('error', reject); this.server.on('upgrade', async (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => { const reqOrigin = req.headers['origin'] as string | undefined; if (this.requiredOrigin && !await strictOriginMatch(reqOrigin, this.requiredOrigin)) { console.warn(`Websocket request from invalid origin: ${req.headers['origin']}`); socket.destroy(); return; } const isSubscriptionRequest = req.url!.match(/^\/(?:server|session)\/([\w\d\-]+)\/subscription$/); const isStreamRequest = req.url!.match(/^\/(?:server|session)\/([\w\d\-]+)\/stream$/); const isMatch = isSubscriptionRequest || isStreamRequest; if (isMatch) { const sessionId = isMatch[1]; let wsServer: Ws.Server = isSubscriptionRequest ? this.sessions[sessionId]?.subscriptionServer.server : this.sessions[sessionId]?.streamServer; if (wsServer) { wsServer.handleUpgrade(req, socket, head, (ws) => { wsServer.emit('connection', ws, req); }); } else { console.warn(`Websocket request for unrecognized mock session: ${sessionId}`); socket.destroy(); } } else { console.warn(`Unrecognized websocket request for ${req.url}`); socket.destroy(); } }); }); } private async startSessionManagementAPI(sessionId: string, plugins: Plugins): Promise<void> { const mockSessionRouter = express.Router(); let running = true; const stopSession = async () => { if (!running) return; running = false; this.eventEmitter.emit('mock-session-stopping', plugins); const session = this.sessions[sessionId]; delete this.sessions[sessionId]; await Promise.all(Object.values(plugins).map(plugin => plugin.stop())); session.subscriptionServer.close(); // Close with code 1000 (purpose is complete - no more streaming happening) session.streamServer.clients.forEach((client) => { client.close(1000); }); session.streamServer.close(); session.streamServer.emit('close'); }; mockSessionRouter.post('/stop', async (req, res) => { await stopSession(); res.json({ success: true }); }); // A pair of sockets, representing the 2-way connection between the session & WSs. // All websocket messages are written to wsSocket, and then read from sessionSocket // All session messages are written to sessionSocket, and then read from wsSocket and sent const { socket1: wsSocket, socket2: sessionSocket } = new DuplexPair(); // This receives 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). sessionSocket.setMaxListeners(100); if (this.debug) { sessionSocket.on('data', (d: any) => { console.log('Streaming data from WS clients:', d.toString()); }); wsSocket.on('data', (d: any) => { console.log('Streaming data to WS clients:', d.toString()); }); } const streamServer = new Ws.Server({ noServer: true }); streamServer.on('connection', (ws) => { let newClientStream = Ws.createWebSocketStream(ws, {}); wsSocket.pipe(newClientStream).pipe(wsSocket, { end: false }); const unpipe = () => { wsSocket.unpipe(newClientStream); newClientStream.unpipe(wsSocket); }; newClientStream.on('error', unpipe); wsSocket.on('end', unpipe); }); streamServer.on('close', () => { wsSocket.end(); sessionSocket.end(); }); // Handle errors by logging & stopping this session const onStreamError = (e: Error) => { if (!running) return; // We don't care about connection issues during shutdown console.error("Error in admin server stream, shutting down mock session"); console.error(e); stopSession(); }; wsSocket.on('error', onStreamError); sessionSocket.on('error', onStreamError); const schema = makeExecutableSchema({ typeDefs: [ AdminServer.baseSchema, ...Object.values(plugins).map(plugin => plugin.schema) ], resolvers: [ this.buildBaseResolvers(sessionId), ...Object.values(plugins).map(plugin => plugin.buildResolvers(sessionSocket, this.ruleParams) ) ] }); const subscriptionServer = SubscriptionServer.create({ schema, execute, subscribe, keepAlive: this.webSocketKeepAlive }, { noServer: true }); mockSessionRouter.use( createGraphQLHandler({ schema, // Add console logging of all GQL errors: formatError: (error) => { console.error(error.stack); return error; } } )); if (this.webSocketKeepAlive) { // If we have a keep-alive set, send the client a ping frame every Xms to // try and stop closes (especially by browsers) due to inactivity. const webSocketKeepAlive = setInterval(() => { [ ...streamServer.clients, ...subscriptionServer.server.clients ].forEach((client) => { if (client.readyState !== Ws.OPEN) return; client.ping(); }); }, this.webSocketKeepAlive); // We use the stream server's shutdown as an easy proxy event for full shutdown: streamServer.on('close', () => clearInterval(webSocketKeepAlive)); } this.sessions[sessionId] = { sessionPlugins: plugins, router: mockSessionRouter, streamServer, subscriptionServer, stop: stopSession }; this.eventEmitter.emit('mock-session-started', plugins, sessionId); } stop(): Promise<void> { if (!this.server) return Promise.resolve(); return Promise.all([ this.server.destroy(), ].concat( Object.values(this.sessions).map((s) => s.stop()) )).then(() => { this.server = null; }); } private static baseSchema = gql` type Mutation { reset: Void enableDebug: Void } type Query { ruleParameterKeys: [String!]! } type Subscription { _empty_placeholder_: Void # A placeholder so we can define an empty extendable type } scalar Void scalar Raw scalar Json scalar Buffer `; private buildBaseResolvers(sessionId: string) { return { Query: { ruleParameterKeys: () => this.ruleParameterKeys }, Mutation: { reset: () => this.resetPluginsForSession(sessionId), enableDebug: () => this.enableDebugForSession(sessionId) }, Raw: new GraphQLScalarType({ name: 'Raw', description: 'A raw entity, serialized directly (must be JSON-compatible)', serialize: (value: any) => value, parseValue: (input: string): any => input, parseLiteral: parseAnyAst }), // Json exists just for API backward compatibility - all new data should be Raw. // Converting to JSON is pointless, since bodies all contain JSON anyway. Json: new GraphQLScalarType({ name: 'Json', description: 'A JSON entity, serialized as a simple JSON string', serialize: (value: any) => JSON.stringify(value), parseValue: (input: string): any => JSON.parse(input), parseLiteral: parseAnyAst }), Void: new GraphQLScalarType({ name: 'Void', description: 'Nothing at all', serialize: (value: any) => null, parseValue: (input: string): any => null, parseLiteral: (): any => { throw new Error('Void literals are not supported') } }), Buffer: new GraphQLScalarType({ name: 'Buffer', description: 'A buffer', serialize: (value: Buffer) => { return value.toString('base64'); }, parseValue: (input: string) => { return Buffer.from(input, 'base64'); }, parseLiteral: parseAnyAst }) }; }; private resetPluginsForSession(sessionId: string) { return Promise.all( Object.values(this.sessions[sessionId].sessionPlugins).map(plugin => plugin.reset?.() ) ); } private enableDebugForSession(sessionId: string) { return Promise.all( Object.values(this.sessions[sessionId].sessionPlugins).map(plugin => plugin.enableDebug?.() ) ); } get ruleParameterKeys() { return Object.keys(this.ruleParams); } }