Commit c2848fc7 authored by Thea Schöbl's avatar Thea Schöbl
Browse files

feat: add plugin class

Fixes #12
parent e32da822
Loading
Loading
Loading
Loading
+1262 −234

File changed.

Preview size limit exceeded, changes collapsed.

+10 −3
Changes for package.json: 10 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -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"

src/plugin-client.ts

0 → 100644
+70 −0
Changes for src/plugin-client.ts: 70 added lines, 0 removed lines.
Original line number Diff line number Diff line
/*
 * 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);
  }
}

src/plugin.ts

0 → 100644
+251 −0
Changes for src/plugin.ts: 251 added lines, 0 removed lines.
Original line number Diff line number Diff line
/*
 * 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;
  }
}

test/TestPlugin.ts

0 → 100644
+30 −0
Changes for test/TestPlugin.ts: 30 added lines, 0 removed lines.
Original line number Diff line number Diff line
/*
 * 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;
  }
}
Loading