From 228fe566fdbb6442b77b854591fb1360682298c9 Mon Sep 17 00:00:00 2001 From: Kevin Delisle Date: Sat, 11 Nov 2017 15:31:35 -0500 Subject: [PATCH] feat(main): RPC Router and Server implementation --- LICENSE | 24 +++++ README.md | 27 ++++++ index.ts | 11 +++ package.json | 48 ++++++++++ src/application.ts | 21 +++++ src/controllers/greet.controller.ts | 13 +++ src/controllers/index.ts | 1 + src/index.ts | 18 ++++ src/models/index.ts | 1 + src/models/person.model.ts | 5 ++ src/servers/index.ts | 2 + src/servers/rpc-router.ts | 52 +++++++++++ src/servers/rpc-server.ts | 38 ++++++++ test/README.md | 4 + test/controllers/greet.controller.test.ts | 40 +++++++++ test/mocha.opts | 1 + test/servers/rpc-router.test.ts | 102 ++++++++++++++++++++++ tsconfig.json | 13 +++ tslint.build.json | 17 ++++ tslint.json | 4 + 20 files changed, 442 insertions(+) create mode 100644 LICENSE create mode 100644 index.ts create mode 100644 package.json create mode 100644 src/application.ts create mode 100644 src/controllers/greet.controller.ts create mode 100644 src/controllers/index.ts create mode 100644 src/index.ts create mode 100644 src/models/index.ts create mode 100644 src/models/person.model.ts create mode 100644 src/servers/index.ts create mode 100644 src/servers/rpc-router.ts create mode 100644 src/servers/rpc-server.ts create mode 100644 test/README.md create mode 100644 test/controllers/greet.controller.test.ts create mode 100644 test/mocha.opts create mode 100644 test/servers/rpc-router.test.ts create mode 100644 tsconfig.json create mode 100644 tslint.build.json create mode 100644 tslint.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d44dd8f --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +Node module: loopback4-example-rpc-server +This project is licensed under the MIT License, full text below. + +-------- + +MIT license + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index 2e8fc08..bddacc4 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,30 @@ An example RPC server and application to demonstrate the creation of your own custom server. [![LoopBack](http://loopback.io/images/overview/powered-by-LB-xs.png)](http://loopback.io/) + +## Usage +Install dependencies and start the app: +```sh +npm install +npm start +``` + +Next, use your favourite REST client to send RPC payloads to the server +(hosted on port 3000). + +## Request Format + +The request body should contain a controller name, method name and input object. +Example: +```json +{ + "controller": "GreetController", + "method": "basicHello", + "input": { + "name": "Janet" + } +} +``` +The router will determine which controller and method will service your request +based on the given names in the payload. + diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..863a1b9 --- /dev/null +++ b/index.ts @@ -0,0 +1,11 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: loopback4-example-rpc-server +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +// NOTE(bajtos) This file is used by TypeScript compiler to resolve imports +// from "test" files against original TypeScript sources in "src" directory. +// As a side effect, `tsc` also produces "dist/index.{js,d.ts,map} files +// that allow test files to import paths pointing to {src,test} root directory, +// which is project root for TS sources but "dist" for transpiled sources. +export * from './src'; diff --git a/package.json b/package.json new file mode 100644 index 0000000..4a7f482 --- /dev/null +++ b/package.json @@ -0,0 +1,48 @@ +{ + "name": "loopback4-example-rpc-server", + "version": "1.0.0", + "description": "A basic RPC server using a made-up protocol.", + "keywords": ["loopback-application", "loopback"], + "main": "index.js", + "engines": { + "node": ">=8" + }, + "scripts": { + "build": "lb-tsc", + "build:watch": "lb-tsc --watch", + "clean": "lb-clean", + "lint": "npm run prettier:check && npm run tslint", + "lint:fix": "npm run prettier:fix && npm run tslint:fix", + "prettier:cli": "lb-prettier \"**/*.ts\"", + "prettier:check": "npm run prettier:cli -- -l", + "prettier:fix": "npm run prettier:cli -- --write", + "tslint": "lb-tslint", + "tslint:fix": "npm run tslint -- --fix", + "pretest": "npm run clean && npm run build", + "test": "lb-dist mocha DIST/test", + "posttest": "npm run lint", + "start": "npm run build && node .", + "prepare": "npm run build" + }, + "repository": { + "type": "git" + }, + "author": "", + "license": "MIT", + "files": ["README.md", "index.js", "index.d.ts", "dist", "dist6"], + "dependencies": { + "@loopback/context": "^4.0.0-alpha.18", + "@loopback/core": "^4.0.0-alpha.20", + "@types/express": "^4.0.39", + "@types/node": "^8.0.51", + "@types/p-event": "^1.3.0", + "express": "^4.16.2", + "p-event": "^1.3.0" + }, + "devDependencies": { + "@loopback/build": "^4.0.0-alpha.5", + "@loopback/testlab": "^4.0.0-alpha.13", + "@types/mocha": "^2.2.43", + "mocha": "^4.0.1" + } +} diff --git a/src/application.ts b/src/application.ts new file mode 100644 index 0000000..6881aa5 --- /dev/null +++ b/src/application.ts @@ -0,0 +1,21 @@ +import {Application, ApplicationConfig} from '@loopback/core'; +import {RPCServer} from './servers/rpc-server'; +import {GreetController} from './controllers'; + +export class MyApplication extends Application { + options: ApplicationConfig; + constructor(options?: ApplicationConfig) { + // Allow options to replace the defined components array, if desired. + super(options); + this.controller(GreetController); + this.server(RPCServer); + this.options = options || {}; + this.options.port = this.options.port || 3000; + this.bind('rpcServer.config').to(this.options); + } + + async start() { + await super.start(); + console.log(`Server is running on port ${this.options.port}`); + } +} diff --git a/src/controllers/greet.controller.ts b/src/controllers/greet.controller.ts new file mode 100644 index 0000000..bfd16fa --- /dev/null +++ b/src/controllers/greet.controller.ts @@ -0,0 +1,13 @@ +import {Person} from '../models'; + +export class GreetController { + basicHello(input: Person) { + return `Hello, ${(input && input.name) || 'World'}!`; + } + + hobbyHello(input: Person) { + return `${this.basicHello(input)} I heard you like ${(input && + input.hobby) || + 'underwater basket weaving'}.`; + } +} diff --git a/src/controllers/index.ts b/src/controllers/index.ts new file mode 100644 index 0000000..92608bd --- /dev/null +++ b/src/controllers/index.ts @@ -0,0 +1 @@ +export * from './greet.controller'; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..fb54873 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,18 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: some-project +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {MyApplication} from './application'; +import {ApplicationConfig} from '@loopback/core'; + +export async function main(options?: ApplicationConfig) { + const app = new MyApplication(options); + + try { + await app.start(); + } catch (err) { + console.error(`Unable to start application: ${err}`); + } + return app; +} diff --git a/src/models/index.ts b/src/models/index.ts new file mode 100644 index 0000000..69af9fd --- /dev/null +++ b/src/models/index.ts @@ -0,0 +1 @@ +export * from './person.model'; diff --git a/src/models/person.model.ts b/src/models/person.model.ts new file mode 100644 index 0000000..ba96633 --- /dev/null +++ b/src/models/person.model.ts @@ -0,0 +1,5 @@ +// Note that this can also be a class! +export type Person = { + name?: string; + hobby?: string; +}; diff --git a/src/servers/index.ts b/src/servers/index.ts new file mode 100644 index 0000000..98553f0 --- /dev/null +++ b/src/servers/index.ts @@ -0,0 +1,2 @@ +export * from './rpc-router'; +export * from './rpc-server'; diff --git a/src/servers/rpc-router.ts b/src/servers/rpc-router.ts new file mode 100644 index 0000000..dd31add --- /dev/null +++ b/src/servers/rpc-router.ts @@ -0,0 +1,52 @@ +import {RPCServer} from './rpc-server'; +import * as express from 'express'; +import * as parser from 'body-parser'; + +export class RPCRouter { + routing: express.Router; + constructor(private server: RPCServer) { + this.routing = express.Router(); + const jsonParser = parser.json(); + this.routing.post('*', jsonParser, (request, response) => {}); + if (this.server.expressServer) { + this.server.expressServer.use(this.routing); + } + } + + async routeRequest(request: express.Request, response: express.Response) { + const ctrl = request.body.controller; + const method = request.body.method; + const input = request.body.input; + let controller; + try { + controller = await this.getController(ctrl); + if (!controller[method]) { + throw new Error( + `No method was found on controller "${ctrl}" with name "${method}".`, + ); + } + } catch (err) { + this.sendErrResponse(response, err, 400); + return; + } + try { + response.send(await controller[method](input)); + } catch (err) { + this.sendErrResponse(response, err, 500); + } + } + + // tslint:disable-next-line:no-any + sendErrResponse(resp: express.Response, send: any, statusCode: number) { + resp.statusCode = statusCode; + resp.send(send); + } + + async getController(ctrl: string): Promise { + return (await this.server.get(`controllers.${ctrl}`)) as Controller; + } +} + +export type Controller = { + [method: string]: Function; +}; diff --git a/src/servers/rpc-server.ts b/src/servers/rpc-server.ts new file mode 100644 index 0000000..d0fc51f --- /dev/null +++ b/src/servers/rpc-server.ts @@ -0,0 +1,38 @@ +import {inject, Context} from '@loopback/context'; +import {Server, Application, CoreBindings} from '@loopback/core'; +import {RPCRouter} from './rpc-router'; +import * as express from 'express'; +import * as http from 'http'; +import * as pEvent from 'p-event'; + +export class RPCServer extends Context implements Server { + _server: http.Server; + expressServer: express.Application; + router: RPCRouter; + constructor( + @inject(CoreBindings.APPLICATION_INSTANCE) public app?: Application, + @inject('rpcServer.config') public config?: RPCServerConfig, + ) { + super(app); + this.config = config || {}; + } + + async start(): Promise { + this.expressServer = express(); + this.router = new RPCRouter(this); + this._server = this.expressServer.listen( + (this.config && this.config.port) || 3000, + ); + return await pEvent(this._server, 'listening'); + } + async stop(): Promise { + this._server.close(); + return await pEvent(this._server, 'close'); + } +} + +export type RPCServerConfig = { + port?: number; + // tslint:disable-next-line:no-any + [key: string]: any; +}; diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..2243988 --- /dev/null +++ b/test/README.md @@ -0,0 +1,4 @@ +# Tests + +Please place your tests in this folder. + diff --git a/test/controllers/greet.controller.test.ts b/test/controllers/greet.controller.test.ts new file mode 100644 index 0000000..5937739 --- /dev/null +++ b/test/controllers/greet.controller.test.ts @@ -0,0 +1,40 @@ +import 'mocha'; +import {GreetController} from '../../src/controllers'; +import {expect} from '@loopback/testlab'; + +describe('greet.controller', () => { + const controller = new GreetController(); + describe('basicHello', () => { + it('returns greetings for a name', () => { + expect(controller.basicHello({})).to.equal('Hello, World!'); + }); + + it('returns greetings for the world without valid input', () => { + const input = { + name: 'Aaron', + }; + const expected = `Hello, ${input.name}!`; + expect(controller.basicHello(input)).to.equal(expected); + }); + }); + describe('hobbyHello', () => { + it('returns greetings for a name', () => { + const input = { + name: 'Aaron', + }; + expect(controller.hobbyHello(input)).to.match( + /Hello, Aaron!(.*)underwater basket weaving/, + ); + }); + + it('returns greetings for a name and hobby', () => { + const input = { + name: 'Aaron', + hobby: 'sportsball', + }; + expect(controller.hobbyHello(input)).to.match( + /Hello, Aaron!(.*)sportsball/, + ); + }); + }); +}); diff --git a/test/mocha.opts b/test/mocha.opts new file mode 100644 index 0000000..4a52320 --- /dev/null +++ b/test/mocha.opts @@ -0,0 +1 @@ +--recursive diff --git a/test/servers/rpc-router.test.ts b/test/servers/rpc-router.test.ts new file mode 100644 index 0000000..1da7f1b --- /dev/null +++ b/test/servers/rpc-router.test.ts @@ -0,0 +1,102 @@ +import 'mocha'; +import * as express from 'express'; +import * as sinon from 'sinon'; +import {RPCRouter, RPCServer} from '../../src/servers'; +import {expect} from '@loopback/testlab'; + +describe('rpc-router', () => { + let router: RPCRouter; + let request: express.Request; + let response: express.Response; + // tslint:disable-next-line:no-any + let controller: any; + + beforeEach(setupFakes); + it('routes to existing controller and method', async () => { + await router.routeRequest(request, response); + const stub = response.send as sinon.SinonStub; + expect(stub.called); + const result = stub.firstCall.args[0]; + expect(result).to.match(/Hello, World!/); + }); + + it('returns error if controller does not exist', async () => { + request = getRequest({ + body: { + controller: 'NotAController', + }, + }); + controller.rejects(new Error('Does not exist!')); + await router.routeRequest(request, response); + const stub = response.send as sinon.SinonStub; + expect(stub.called); + expect(response.statusCode).to.equal(400); + const result = stub.firstCall.args[0]; + expect(result).to.match(/Does not exist!/); + }); + + it('returns error if method does not exist', async () => { + request = getRequest({ + body: { + controller: 'FakeController', + method: 'notReal', + input: { + name: 'World', + }, + }, + }); + await router.routeRequest(request, response); + const stub = response.send as sinon.SinonStub; + expect(stub.called); + expect(response.statusCode).to.equal(400); + const result = stub.firstCall.args[0]; + expect(result).to.match(/No method was found on controller/); + }); + + function getRouter() { + const server = sinon.createStubInstance(RPCServer); + return new RPCRouter(server); + } + + function getController(rtr: RPCRouter) { + const stub = sinon.stub(rtr, 'getController'); + stub.resolves(new FakeController()); + return stub; + } + + function getRequest(req?: Partial) { + const reqt = Object.assign( + { + body: { + controller: 'FakeController', + method: 'getFoo', + input: { + name: 'World', + }, + }, + }, + req, + ); + return reqt; + } + + function getResponse(res?: Partial) { + const resp = {}; + resp.send = sinon.stub(); + return resp; + } + + function setupFakes() { + router = getRouter(); + request = getRequest(); + response = getResponse(); + controller = getController(router); + } + + class FakeController { + // tslint:disable-next-line:no-any + getFoo(input: any) { + return `Hello, ${input.name}!`; + } + } +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d0c8f64 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "./node_modules/@loopback/build/config/tsconfig.common.json", + "include": [ + "src", + "test" + ], + "exclude": [ + "node_modules/**", + "packages/*/node_modules/**", + "**/*.d.ts" + ] +} diff --git a/tslint.build.json b/tslint.build.json new file mode 100644 index 0000000..11aa5c8 --- /dev/null +++ b/tslint.build.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json.schemastore.org/tslint", + "extends": ["./tslint.json"], + // This configuration files enabled rules which require type checking + // and therefore cannot be run by Visual Studio Code TSLint extension + // See https://github.com/Microsoft/vscode-tslint/issues/70 + "rules": { + // These rules find errors related to TypeScript features. + + // These rules catch common errors in JS programming or otherwise + // confusing constructs that are prone to producing bugs. + + "await-promise": true, + "no-floating-promises": true, + "no-void-expression": [true, "ignore-arrow-function-shorthand"] + } +} diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..9b70ba6 --- /dev/null +++ b/tslint.json @@ -0,0 +1,4 @@ +{ + "$schema": "http://json.schemastore.org/tslint", + "extends": ["./node_modules/@loopback/build/config/tslint.common.json"] + }