feat: add plugin class

Fixes #12
This commit is contained in:
Wieland Schöbl
2019-04-23 14:13:17 +02:00
parent e32da822e1
commit c2848fc7a5
7 changed files with 1878 additions and 237 deletions

1496
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,19 +18,25 @@
"dependencies": {
"@krlwlfrt/async-pool": "0.1.0",
"@openstapps/core": "0.26.0",
"@openstapps/core-tools": "0.8.0",
"@openstapps/logger": "0.4.0",
"@types/cli-progress": "1.8.1",
"@types/express": "4.16.1",
"@types/morgan": "1.7.37",
"@types/node": "10.14.15",
"@types/request": "2.48.2",
"@types/traverse": "0.6.32",
"@types/uuid": "3.4.5",
"cli-progress": "3.0.0",
"commander": "3.0.0",
"express": "4.16.1",
"fast-clone": "1.5.13",
"jsonschema": "1.2.4",
"moment": "2.24.0",
"morgan": "1.9.1",
"request": "2.88.0",
"traverse": "0.6.6",
"uuid": "3.3.2"
"uuid": "3.3.3"
},
"license": "GPL-3.0-only",
"devDependencies": {
@@ -53,7 +59,7 @@
"prepend-file-cli": "1.0.6",
"rimraf": "3.0.0",
"ts-node": "8.3.0",
"tslint": "5.18.0",
"tslint": "5.19.0",
"typedoc": "0.15.0",
"typescript": "3.5.3"
},
@@ -64,7 +70,8 @@
"Jovan Krunić <jovan.krunic@gmail.com>",
"Michel Jonathan Schmitz",
"Rainer Killinger",
"Roman Klopsch"
"Roman Klopsch",
"Wieland Schöbl <wulkanat@gmail.com>"
],
"peerDependencies": {
"@openstapps/core": "~0.23.1"

70
src/plugin-client.ts Normal file
View File

@@ -0,0 +1,70 @@
/*
* Copyright (C) 2019 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 <https://www.gnu.org/licenses/>.
*/
import {SCPluginRegisterRequest, SCPluginRegisterRoute} from '@openstapps/core';
import {ConnectorClient} from './connector-client';
import {Plugin} from './plugin';
/**
* The PluginClient for registering and unregistering HTTP Plugins
*
* It contains a lot of the boilerplate for creating plugins, and thus simplifies the creation of such.
*/
export class PluginClient extends ConnectorClient {
/**
* Register a plugin in the backend
*
* **This method automatically calls [[Plugin.start]]**
* You need to call this method before you can do anything with the plugin. If you want to register the plugin again,
* you might first want to inform yourself how the backend behaves in such cases TODO: add docs for this
*
* @param plugin The instance of the plugin you want to register
*/
async registerPlugin(plugin: Plugin) {
const request: SCPluginRegisterRequest = {
action: 'add',
plugin: {
address: plugin.fullUrl,
name: plugin.name,
requestSchema: plugin.requestSchema,
responseSchema: plugin.responseSchema,
route: plugin.route,
},
};
await this.invokeRoute(new SCPluginRegisterRoute(), undefined, request);
// start the plugin we just registered
plugin.start();
}
/**
* Unregister a plugin from the backend
*
* **This method automatically calls [[Plugin.stop]]**
* If you want to unregister your plugin for some reason, you can do so by calling this method.
* *Use with caution.*
*
* @param plugin The instance of the plugin you want to register
*/
async unregisterPlugin(plugin: Plugin) {
const request: SCPluginRegisterRequest = {
action: 'remove',
route: plugin.route,
};
// stop the plugin we want to unregister
plugin.stop();
await this.invokeRoute(new SCPluginRegisterRoute(), undefined, request);
}
}

251
src/plugin.ts Normal file
View File

@@ -0,0 +1,251 @@
/*
* Copyright (C) 2019 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 <https://www.gnu.org/licenses/>.
*/
import {Converter} from '@openstapps/core-tools/lib/schema';
import {Logger} from '@openstapps/logger';
import * as express from 'express';
import * as http from 'http';
import * as http2 from 'http2';
import {Schema} from 'jsonschema';
import * as 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: Schema = {};
/**
* The schema of the response interfaces defined by the user
*/
public readonly responseSchema: Schema = {};
/**
* Normalize a port into a number, string, or false.
*
* @param value the port you want to normalize
*/
protected static normalizePort(value: string) {
const portNumber = parseInt(value, 10);
/* istanbul ignore next */
if (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.port = Plugin.normalizePort(
/* istanbul ignore next */
typeof process.env.PORT !== 'undefined' ? process.env.PORT : port.toString());
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', (err) => {
/* istanbul ignore next */
this.onError(err);
});
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 (req, res) => {
if (this.active) {
await this.onRouteInvoke(req, res);
} else {
res.status(http2.constants.HTTP_STATUS_NOT_FOUND);
res.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();
const bind = typeof addr === 'string'
/* istanbul ignore next */
? `pipe ${addr}` : addr === null
/* istanbul ignore next */
? 'null' : `port ${addr.port}`;
Logger.ok(`Listening on ${bind}`);
}
/**
* When the route gets invoked
*
* Override this method for your own plugin
*
* @param req An express Request from the backend
* @param res An express Response to the backend for you to send back data
*/
protected abstract async onRouteInvoke(req: express.Request, res: express.Response): Promise<void>;
/**
* 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((err) => {
/* istanbul ignore next */
if (typeof err !== 'undefined') {
/* istanbul ignore next */
return reject(err);
}
resolve();
});
});
}
/**
* 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;
}
}

30
test/TestPlugin.ts Normal file
View File

@@ -0,0 +1,30 @@
/*
* Copyright (C) 2019 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 <https://www.gnu.org/licenses/>.
*/
import * as express from 'express';
import {Plugin} from '../src/plugin';
/**
* A test plugin we use for all the tests
*
* It can be constructed without any parameter at all, or with all parameters if we want to test it
* It also serves as kind of a minimal plugin
*/
export class TestPlugin extends Plugin {
protected async onRouteInvoke(_req: express.Request, res: express.Response): Promise<void> {
res.json({});
return undefined;
}
}

114
test/plugin-client.spec.ts Normal file
View File

@@ -0,0 +1,114 @@
/*
* Copyright (C) 2019 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 <https://www.gnu.org/licenses/>.
*/
import {SCPluginRegisterRequest, SCPluginRegisterResponse, SCPluginRegisterRoute} from '@openstapps/core';
import * as chai from 'chai';
import {expect} from 'chai';
import * as chaiSpies from 'chai-spies';
import {suite, test, timeout} from 'mocha-typescript';
import {HttpClient} from '../src/http-client';
import {HttpClientResponse} from '../src/http-client-interface';
import {PluginClient} from '../src/plugin-client';
import {TestPlugin} from './TestPlugin';
chai.use(chaiSpies);
const sandbox = chai.spy.sandbox();
const httpClient = new HttpClient();
const pluginRegisterRoute = new SCPluginRegisterRoute();
const pluginClient = new PluginClient(httpClient, 'http://localhost');
@suite(timeout(10000))
export class PluginClientSpec {
static plugin: TestPlugin;
static async after() {
await this.plugin.close();
}
static async before() {
this.plugin = new TestPlugin(4000, '', '', '', '', {getSchema: () => {/***/}} as any, '', '', '');
}
async after() {
sandbox.restore();
}
@test
async registerPlugin() {
sandbox.on(httpClient, 'request', async (): Promise<HttpClientResponse<SCPluginRegisterResponse>> => {
return {
body: {
success: true,
},
headers: {},
statusCode: pluginRegisterRoute.statusCodeSuccess,
};
});
expect(httpClient.request).not.to.have.been.called();
await pluginClient.registerPlugin(PluginClientSpec.plugin);
const request: SCPluginRegisterRequest = {
action: 'add',
plugin: {
address: PluginClientSpec.plugin.fullUrl,
name: PluginClientSpec.plugin.name,
requestSchema: PluginClientSpec.plugin.requestSchema,
responseSchema: PluginClientSpec.plugin.responseSchema,
route: PluginClientSpec.plugin.route,
},
};
expect(httpClient.request).to.have.been.first.called.with({
body: request,
headers: {},
method: pluginRegisterRoute.method,
url: new URL(`http://localhost${pluginRegisterRoute.getUrlFragment()}`),
});
}
@test
async unregisterPlugin() {
sandbox.on(httpClient, 'request', async (): Promise<HttpClientResponse<SCPluginRegisterResponse>> => {
return {
body: {
success: true,
},
headers: {},
statusCode: pluginRegisterRoute.statusCodeSuccess,
};
});
expect(httpClient.request).not.to.have.been.called();
await pluginClient.unregisterPlugin(PluginClientSpec.plugin);
const request: SCPluginRegisterRequest = {
action: 'remove',
route: PluginClientSpec.plugin.route,
};
expect(httpClient.request).to.have.been.first.called.with({
body: request,
headers: {},
method: pluginRegisterRoute.method,
url: new URL(`http://localhost${pluginRegisterRoute.getUrlFragment()}`),
});
}
}

141
test/plugin.spec.ts Normal file
View File

@@ -0,0 +1,141 @@
/*
* Copyright (C) 2019 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 <https://www.gnu.org/licenses/>.
*/
import {Converter} from '@openstapps/core-tools/lib/schema';
import * as chai from 'chai';
import {expect} from 'chai';
import * as chaiSpies from 'chai-spies';
import {readFileSync} from 'fs';
import {suite, test, timeout} from 'mocha-typescript';
import {resolve} from 'path';
import {HttpClient} from '../src/http-client';
import {TestPlugin} from './TestPlugin';
chai.use(chaiSpies);
process.on('unhandledRejection', (err) => {
throw err;
});
const sandbox = chai.spy.sandbox();
const httpClient = new HttpClient();
@suite(timeout(20000))
export class PluginSpec {
static testPlugin: TestPlugin;
static async after() {
PluginSpec.testPlugin.close();
}
static async before() {
PluginSpec.testPlugin = new TestPlugin(4000, '', '', '', '', {
getSchema: () => {/***/
},
} as any, '', '', '');
}
async after() {
sandbox.restore();
}
@test
async construct() {
const converter = new Converter(__dirname);
sandbox.on(converter, 'getSchema', (schemaName) => {
return {id: schemaName};
});
const constructTestPlugin = new TestPlugin(
4001,
'A',
'http://B',
'/C', // this doesn't matter for our tests, it's only something that affects the backend
'http://D',
// @ts-ignore fake converter is not a converter
converter,
'PluginTestRequest',
'PluginTestResponse',
JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json')).toString()).version,
);
expect(constructTestPlugin.port).to.be.equal(4001);
expect(constructTestPlugin.name).to.be.equal('A');
expect(constructTestPlugin.url).to.be.equal('http://B');
expect(constructTestPlugin.route).to.be.equal('/C');
// @ts-ignore backendUrl is protected
expect(constructTestPlugin.backendUrl).to.be.equal('http://D');
// schemas are already covered, together with the directory and version
// @ts-ignore active is private
expect(constructTestPlugin.active).to.be.equal(false);
expect(constructTestPlugin.requestSchema.id).to.be.equal('PluginTestRequest');
expect(constructTestPlugin.responseSchema.id).to.be.equal('PluginTestResponse');
sandbox.on(constructTestPlugin, 'onRouteInvoke');
await httpClient.request({
url: new URL('http://localhost:4001'),
});
// onRouteInvoke is a protected method, but we need to access it from the outside to test it
// @ts-ignore
expect(constructTestPlugin.onRouteInvoke).not.to.have.been.called();
await constructTestPlugin.close();
sandbox.restore(constructTestPlugin, 'onRouteInvoke');
}
@test
async fullUrl() {
const constructTestPlugin = new TestPlugin(4001, '', 'http://B', '', '', {
getSchema: () => {/***/
},
} as any, '', '', '');
expect(constructTestPlugin.fullUrl).to.be.equal('http://B:4001');
await constructTestPlugin.close();
}
@test
async start() {
PluginSpec.testPlugin.start();
sandbox.on(PluginSpec.testPlugin, 'onRouteInvoke');
await httpClient.request({
url: new URL('http://localhost:4000'),
});
// onRouteInvoke is a protected method, but we need to access it from the outside to test it
// @ts-ignore
expect(PluginSpec.testPlugin.onRouteInvoke).to.have.been.called();
}
@test
async stop() {
// simulate a normal use case by first starting the plugin and then stopping it
PluginSpec.testPlugin.start();
PluginSpec.testPlugin.stop();
sandbox.on(PluginSpec.testPlugin, 'onRouteInvoke');
const response = await httpClient.request({
url: new URL('http://localhost:4000'),
});
await expect(response.statusCode).to.be.equal(404);
// onRouteInvoke is a protected method, but we need to access it from the outside to test it
// @ts-ignore
expect(PluginSpec.testPlugin.onRouteInvoke).not.to.have.been.called();
}
}