/* * Copyright (C) 2019-2022 Open StApps * This program is free software: you can redistribute it and/or modify it * under the terms of the GNU General Public License as published by the Free * Software Foundation, version 3. * * This program is distributed in the hope that it will be useful, but WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for * more details. * * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ import {Converter} from '@openstapps/core-tools'; import {Logger} from '@openstapps/logger'; import bodyParser from 'body-parser'; import express from 'express'; import * as http from 'http'; import * as http2 from 'http2'; import {JSONSchema7} from 'json-schema'; import morgan from 'morgan'; import ErrnoException = NodeJS.ErrnoException; /** * The Plugin for creating HTTP backend plugins * * It contains a lot of the boilerplate for creating plugins, and thus simplifies the creation of such. * To create your own plugin, you need to extend this class and implement the [[Plugin.onRouteInvoke]] method */ export abstract class Plugin { /** * The express instance */ private readonly app = express(); /** * The HTTP server */ private readonly server: http.Server; /** * Whether the server is active or not * * When active is false, it will return 404 on all routes. */ protected active = false; /** * The full URL of the plugin * * The full URL of the plugin consists out of URL:PORT */ public get fullUrl() { return `${this.url}:${this.port}`; } /** * The port on which the plugin will listen on */ public port: string | number | false; /** * The schema of the request interfaces defined by the user */ public readonly requestSchema: JSONSchema7 = {}; /** * The schema of the response interfaces defined by the user */ public readonly responseSchema: JSONSchema7 = {}; /** * Normalize a port into a number, string, or false. * @param value the port you want to normalize */ protected static normalizePort(value: string) { const portNumber = Number.parseInt(value, 10); /* istanbul ignore next */ if (Number.isNaN(portNumber)) { // named pipe /* istanbul ignore next */ return value; } /* istanbul ignore next */ if (portNumber >= 0) { // port number return portNumber; } /* istanbul ignore next */ return false; } /** * Create an instance of the PluginClient * * Don't forget to call [[PluginClient.registerPlugin]]! * Refer to the examples for how to use the schemas. TODO: examples * @param port The port of the plugin * @param name The name of the plugin * @param url The url of the plugin without the port or anything else, for example `http://localhost` * @param route The desired route that will be registered in the backend * @param backendUrl The url of the backend * @param converter If you want to use an already existing converter, you can pass it here * @param requestName the name of the request schema * @param responseName the name of the response schema * @param version the version. You should retrieve it from the package.json */ constructor( port: number, public name: string, public url: string, public route: string, protected backendUrl: string, converter: Converter, requestName: string, responseName: string, version: string, ) { this.app.use(bodyParser.json()); this.port = Plugin.normalizePort( /* istanbul ignore next */ process.env.PORT === undefined ? port.toString() : process.env.PORT, ); this.app.set('port', this.port); // setup express this.server = http.createServer(this.app); this.server.listen(this.port); /* istanbul ignore next */ this.server.on('error', error => { /* istanbul ignore next */ this.onError(error); }); this.server.on('listening', () => { this.onListening(); }); this.requestSchema = converter.getSchema(requestName, version); this.responseSchema = converter.getSchema(responseName, version); this.app.use(morgan('dev')); this.app.set('env', process.env.NODE_ENV); this.app.all('*', async (request: express.Request, response: express.Response) => { if (this.active) { await this.onRouteInvoke(request, response); } else { response.status(http2.constants.HTTP_STATUS_NOT_FOUND); response.send(); } }); } /** * Event listener for HTTP server "error" event. * @param error The error that occurred */ /* istanbul ignore next */ private onError(error: ErrnoException) { if (error.syscall !== 'listen') { throw error; } const bind = typeof this.port === 'string' ? `Pipe ${this.port}` : `Port ${this.port}`; // handle specific listen errors with friendly messages switch (error.code) { case 'EACCES': { // tslint:disable-next-line:no-floating-promises Logger.error(`${bind} requires elevated privileges`); process.exit(1); break; } case 'EADDRINUSE': { // tslint:disable-next-line:no-floating-promises Logger.error(`${bind} is already in use`); process.exit(1); break; } default: { throw error; } } } /** * Event listener for HTTP server "listening" event. */ private onListening() { const addr = this.server.address(); /* istanbul ignore next */ const bind = typeof addr === 'string' ? `pipe ${addr}` : addr === null ? 'null' : `port ${addr.port}`; Logger.ok(`Listening on ${bind}`); } /** * When the route gets invoked * * Override this method for your own plugin * @param request An express Request from the backend * @param response An express Response to the backend for you to send back data */ protected abstract onRouteInvoke(request: express.Request, response: express.Response): Promise; /** * Closes the server * * This will stop the plugin from listening to any requests at all, and is currently an irreversible process. * This means, that the instantiated plugin is basically useless afterwards. */ public async close() { return new Promise((resolve, reject) => { this.server.close(error => { /* istanbul ignore next */ if (error !== undefined) { /* istanbul ignore next */ reject(error); } resolve(undefined); }); }); } /** * Start the plugin * * **THIS METHOD GETS CALLED AUTOMATICALLY WITH [[PluginClient.registerPlugin]]** * If the plugin is not started, it will return 404 on any route */ public start() { this.active = true; } /** * Stop the plugin * * **THIS METHOD GETS CALLED AUTOMATICALLY WITH [[PluginClient.unregisterPlugin]]** * If the plugin is not started, it will return 404 on any route */ public stop() { // You can't unregister routes from express. So this is a workaround. this.active = false; } }