mockttp
Version:
Mock HTTP server for testing HTTP clients and stubbing webservices
394 lines (391 loc) • 17.5 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.AdminServer = void 0;
const buffer_1 = require("buffer");
const _ = require("lodash");
const express = require("express");
const cors = require("cors");
const corsGate = require("cors-gate");
const bodyParser = require("body-parser");
const Ws = require("ws");
const express_1 = require("graphql-http/lib/use/express");
const graphql_1 = require("graphql");
const graphql_tag_1 = require("graphql-tag");
const schema_1 = require("@graphql-tools/schema");
const subscriptions_transport_ws_1 = require("@httptoolkit/subscriptions-transport-ws");
const stream_1 = require("stream");
const DuplexPair = require("native-duplexpair");
const destroyable_server_1 = require("destroyable-server");
const util_1 = require("@httptoolkit/util");
const promise_1 = require("../util/promise");
const types_1 = require("../types");
const graphql_utils_1 = require("./graphql-utils");
async function strictOriginMatch(origin, expectedOrigin) {
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((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;
}
class AdminServer {
constructor(options = {}) {
this.app = express();
this.server = null;
this.eventEmitter = new stream_1.EventEmitter();
this.sessions = {};
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 || {};
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 = options.pluginDefaults ?? {};
this.app.post('/start', async (req, res) => {
try {
const rawConfig = req.body;
const providedPluginStartParams = rawConfig.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) => {
const PluginType = this.adminPlugins[pluginId];
return new PluginType();
});
const pluginStartResults = await (0, promise_1.objectAllPromise)(_.mapValues(sessionPlugins, (plugin, pluginId) => plugin.start(pluginStartParams[pluginId])));
const sessionId = crypto.randomUUID();
await this.startSessionManagementAPI(sessionId, sessionPlugins);
res.json({
id: sessionId,
pluginData: _.mapValues(pluginStartResults, (r) => r ?? {} // Always return _something_, even if the plugin returns null/undefined.
)
});
}
catch (e) {
res.status(500).json({ error: `Failed to start mock session: ${((0, util_1.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: ((0, util_1.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, res, next) => {
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()));
}
on(event, listener) {
this.eventEmitter.on(event, listener);
}
async start(listenOptions = types_1.DEFAULT_ADMIN_SERVER_PORT) {
if (this.server)
throw new Error('Admin server already running');
await new Promise((resolve, reject) => {
this.server = (0, destroyable_server_1.makeDestroyable)(this.app.listen(listenOptions, resolve));
this.server.on('error', reject);
this.server.on('upgrade', async (req, socket, head) => {
const reqOrigin = req.headers['origin'];
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 = 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();
}
});
});
}
async startSessionManagementAPI(sessionId, plugins) {
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) => {
console.log('Streaming data from WS clients:', d.toString());
});
wsSocket.on('data', (d) => {
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) => {
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 = (0, schema_1.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 = subscriptions_transport_ws_1.SubscriptionServer.create({
schema,
execute: graphql_1.execute,
subscribe: graphql_1.subscribe,
keepAlive: this.webSocketKeepAlive
}, {
noServer: true
});
mockSessionRouter.use((0, express_1.createHandler)({
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() {
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;
});
}
buildBaseResolvers(sessionId) {
return {
Query: {
ruleParameterKeys: () => this.ruleParameterKeys
},
Mutation: {
reset: () => this.resetPluginsForSession(sessionId),
enableDebug: () => this.enableDebugForSession(sessionId)
},
Raw: new graphql_1.GraphQLScalarType({
name: 'Raw',
description: 'A raw entity, serialized directly (must be JSON-compatible)',
serialize: (value) => value,
parseValue: (input) => input,
parseLiteral: graphql_utils_1.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 graphql_1.GraphQLScalarType({
name: 'Json',
description: 'A JSON entity, serialized as a simple JSON string',
serialize: (value) => JSON.stringify(value),
parseValue: (input) => JSON.parse(input),
parseLiteral: graphql_utils_1.parseAnyAst
}),
Void: new graphql_1.GraphQLScalarType({
name: 'Void',
description: 'Nothing at all',
serialize: (value) => null,
parseValue: (input) => null,
parseLiteral: () => { throw new Error('Void literals are not supported'); }
}),
Buffer: new graphql_1.GraphQLScalarType({
name: 'Buffer',
description: 'A buffer',
serialize: (value) => {
return value.toString('base64');
},
parseValue: (input) => {
return buffer_1.Buffer.from(input, 'base64');
},
parseLiteral: graphql_utils_1.parseAnyAst
})
};
}
;
resetPluginsForSession(sessionId) {
return Promise.all(Object.values(this.sessions[sessionId].sessionPlugins).map(plugin => plugin.reset?.()));
}
enableDebugForSession(sessionId) {
return Promise.all(Object.values(this.sessions[sessionId].sessionPlugins).map(plugin => plugin.enableDebug?.()));
}
get ruleParameterKeys() {
return Object.keys(this.ruleParams);
}
}
exports.AdminServer = AdminServer;
AdminServer.baseSchema = (0, graphql_tag_1.default) `
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
`;
//# sourceMappingURL=admin-server.js.map