diff --git a/CODEOWNERS b/CODEOWNERS index db2a964ee181..b882bb5a4b1d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -11,6 +11,7 @@ packages/context/* @bajtos @raymondfeng @kjdelisle packages/core/* @bajtos @raymondfeng @kjdelisle packages/example-getting-started/* @bajtos @kjdelisle packages/example-log-extension/* @virkt25 +packages/example-rpc-server/* @kjdelisle packages/metadata/* @raymondfeng packages/openapi-spec/* @bajtos @jannyHou packages/openapi-spec-builder/* @bajtos @raymondfeng diff --git a/packages/cli/generators/example/index.js b/packages/cli/generators/example/index.js index 93dfaeae98d1..8faa04539685 100644 --- a/packages/cli/generators/example/index.js +++ b/packages/cli/generators/example/index.js @@ -16,6 +16,7 @@ const EXAMPLES = { 'An application and tutorial on how to build with LoopBack 4.', 'log-extension': 'An example extension project for LoopBack 4', + 'rpc-server': 'A basic RPC server using a made-up protocol.', }; Object.freeze(EXAMPLES); diff --git a/packages/example-getting-started/README.md b/packages/example-getting-started/README.md index 558b6360b212..cc8700d62b3f 100644 --- a/packages/example-getting-started/README.md +++ b/packages/example-getting-started/README.md @@ -28,7 +28,7 @@ lb4 example getting-started 3. Switch to the directory and install dependencies. ``` -cd loopback4-example-getting-started && npm i +cd loopback-example-getting-started && npm i ``` 4. Start the app! diff --git a/packages/example-rpc-server/.prettierignore b/packages/example-rpc-server/.prettierignore new file mode 100644 index 000000000000..f84e220671a5 --- /dev/null +++ b/packages/example-rpc-server/.prettierignore @@ -0,0 +1,2 @@ +*.js +*.d.ts diff --git a/packages/example-rpc-server/LICENSE b/packages/example-rpc-server/LICENSE new file mode 100644 index 000000000000..065cc147fcce --- /dev/null +++ b/packages/example-rpc-server/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) IBM Corp. 2018. All Rights Reserved. +Node module: @loopback/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/packages/example-rpc-server/README.md b/packages/example-rpc-server/README.md new file mode 100644 index 000000000000..a39fed335724 --- /dev/null +++ b/packages/example-rpc-server/README.md @@ -0,0 +1,48 @@ +# @loopback/example-rpc-server + +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 + +1. Install the new loopback CLI toolkit. +``` +npm i -g @loopback/cli +``` + +2. Download the "rpc-server" application. +``` +lb4 example rpc-server +``` + +3. Switch to the directory and install dependencies. +``` +cd loopback-example-rpc-server && npm i +``` + +4. Start the app! +``` +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/packages/example-rpc-server/index.d.ts b/packages/example-rpc-server/index.d.ts new file mode 100644 index 000000000000..13ed083fde3a --- /dev/null +++ b/packages/example-rpc-server/index.d.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/example-getting-started +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './dist'; diff --git a/packages/example-rpc-server/index.js b/packages/example-rpc-server/index.js new file mode 100644 index 000000000000..20cb263c2a1f --- /dev/null +++ b/packages/example-rpc-server/index.js @@ -0,0 +1,14 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/example-getting-started +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +const nodeMajorVersion = +process.versions.node.split('.')[0]; +const dist = nodeMajorVersion >= 7 ? './dist' : './dist6'; + +const application = (module.exports = require(dist)); + +if (require.main === module) { + // Run the application + application.main(); +} diff --git a/packages/example-rpc-server/index.ts b/packages/example-rpc-server/index.ts new file mode 100644 index 000000000000..3e1026e0d517 --- /dev/null +++ b/packages/example-rpc-server/index.ts @@ -0,0 +1,11 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/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/packages/example-rpc-server/package.json b/packages/example-rpc-server/package.json new file mode 100644 index 000000000000..3dce267ab204 --- /dev/null +++ b/packages/example-rpc-server/package.json @@ -0,0 +1,61 @@ +{ + "name": "@loopback/example-rpc-server", + "version": "4.0.0-alpha.0", + "description": "A basic RPC server using a made-up protocol.", + "keywords": [ + "loopback-application", + "loopback" + ], + "engines": { + "node": ">=6" + }, + "scripts": { + "build": "npm run build:dist && npm run build:dist6", + "build:current": "lb-tsc", + "build:dist": "lb-tsc es2017", + "build:dist6": "lb-tsc es2015", + "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:current", + "test": "lb-dist mocha --opts node_modules/@loopback/build/mocha.ts.opts DIST/test", + "posttest": "npm run lint", + "start": "npm run build && node .", + "prepare": "npm run build" + }, + "repository": { + "type": "git", + "url": "https://github.com/strongloop/loopback-next.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": "^5.0.0", + "source-map-support": "^0.5.2" + } +} diff --git a/packages/example-rpc-server/src/application.ts b/packages/example-rpc-server/src/application.ts new file mode 100644 index 000000000000..fff6c9ed4f95 --- /dev/null +++ b/packages/example-rpc-server/src/application.ts @@ -0,0 +1,26 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/example-rpc-server +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +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/packages/example-rpc-server/src/controllers/greet.controller.ts b/packages/example-rpc-server/src/controllers/greet.controller.ts new file mode 100644 index 000000000000..7405b08ee36c --- /dev/null +++ b/packages/example-rpc-server/src/controllers/greet.controller.ts @@ -0,0 +1,18 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/example-rpc-server +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +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/packages/example-rpc-server/src/controllers/index.ts b/packages/example-rpc-server/src/controllers/index.ts new file mode 100644 index 000000000000..a9cd16687a64 --- /dev/null +++ b/packages/example-rpc-server/src/controllers/index.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/example-rpc-server +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './greet.controller'; diff --git a/packages/example-rpc-server/src/index.ts b/packages/example-rpc-server/src/index.ts new file mode 100644 index 000000000000..c2c78068b553 --- /dev/null +++ b/packages/example-rpc-server/src/index.ts @@ -0,0 +1,22 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/example-rpc-server +// 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; +} + +main().catch(err => { + console.error('Unhandled exception!'); +}); diff --git a/packages/example-rpc-server/src/models/index.ts b/packages/example-rpc-server/src/models/index.ts new file mode 100644 index 000000000000..01eb22361169 --- /dev/null +++ b/packages/example-rpc-server/src/models/index.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/example-rpc-server +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './person.model'; diff --git a/packages/example-rpc-server/src/models/person.model.ts b/packages/example-rpc-server/src/models/person.model.ts new file mode 100644 index 000000000000..885bc16eda1f --- /dev/null +++ b/packages/example-rpc-server/src/models/person.model.ts @@ -0,0 +1,10 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/example-rpc-server +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +// Note that this can also be a class! +export type Person = { + name?: string; + hobby?: string; +}; diff --git a/packages/example-rpc-server/src/servers/index.ts b/packages/example-rpc-server/src/servers/index.ts new file mode 100644 index 000000000000..a273af0a0ae6 --- /dev/null +++ b/packages/example-rpc-server/src/servers/index.ts @@ -0,0 +1,7 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/example-rpc-server +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './rpc-router'; +export * from './rpc-server'; diff --git a/packages/example-rpc-server/src/servers/rpc-router.ts b/packages/example-rpc-server/src/servers/rpc-router.ts new file mode 100644 index 000000000000..4340c4f17e0b --- /dev/null +++ b/packages/example-rpc-server/src/servers/rpc-router.ts @@ -0,0 +1,56 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/example-rpc-server +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {RPCServer} from './rpc-server'; +import * as express from 'express'; +import * as parser from 'body-parser'; + +export function rpcRouter(server: RPCServer) { + const jsonParser = parser.json(); + server.expressServer.post('*', jsonParser, async (request, response) => { + await routeHandler(server, request, response); + }); +} + +export async function routeHandler( + server: RPCServer, + 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 server.get(`controllers.${ctrl}`); + if (!controller[method]) { + throw new Error( + `No method was found on controller "${ctrl}" with name "${method}".`, + ); + } + } catch (err) { + sendErrResponse(response, err, 400); + return; + } + try { + response.send(await controller[method](input)); + } catch (err) { + sendErrResponse(response, err, 500); + } +} + +export type Controller = { + [method: string]: Function; +}; + +function sendErrResponse( + resp: express.Response, + // tslint:disable-next-line:no-any + send: any, + statusCode: number, +) { + resp.statusCode = statusCode; + resp.send(send); +} diff --git a/packages/example-rpc-server/src/servers/rpc-server.ts b/packages/example-rpc-server/src/servers/rpc-server.ts new file mode 100644 index 000000000000..5a6bfddcf878 --- /dev/null +++ b/packages/example-rpc-server/src/servers/rpc-server.ts @@ -0,0 +1,42 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/example-rpc-server +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {inject, Context} from '@loopback/context'; +import {Server, Application, CoreBindings} from '@loopback/core'; +import * as express from 'express'; +import * as http from 'http'; +import * as pEvent from 'p-event'; +import {rpcRouter} from '.'; + +export class RPCServer extends Context implements Server { + _server: http.Server; + expressServer: express.Application; + constructor( + @inject(CoreBindings.APPLICATION_INSTANCE) public app?: Application, + @inject('rpcServer.config') public config?: RPCServerConfig, + ) { + super(app); + this.config = config || {}; + this.expressServer = express(); + rpcRouter(this); + } + + async start(): Promise { + 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/packages/example-rpc-server/test/README.md b/packages/example-rpc-server/test/README.md new file mode 100644 index 000000000000..2243988ea7e1 --- /dev/null +++ b/packages/example-rpc-server/test/README.md @@ -0,0 +1,4 @@ +# Tests + +Please place your tests in this folder. + diff --git a/packages/example-rpc-server/test/controllers/greet.controller.test.ts b/packages/example-rpc-server/test/controllers/greet.controller.test.ts new file mode 100644 index 000000000000..6ccb50f00f03 --- /dev/null +++ b/packages/example-rpc-server/test/controllers/greet.controller.test.ts @@ -0,0 +1,45 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/example-rpc-server +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +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 the world without valid input', () => { + expect(controller.basicHello({})).to.equal('Hello, World!'); + }); + + it('returns greetings for a name', () => { + 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/packages/example-rpc-server/test/servers/rpc-router.test.ts b/packages/example-rpc-server/test/servers/rpc-router.test.ts new file mode 100644 index 000000000000..402839f89c51 --- /dev/null +++ b/packages/example-rpc-server/test/servers/rpc-router.test.ts @@ -0,0 +1,106 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/example-rpc-server +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import 'mocha'; +import * as express from 'express'; +import {RPCServer, routeHandler} from '../../src/servers'; +import {expect, sinon} from '@loopback/testlab'; + +describe('rpcRouter', () => { + describe('routeHandler', () => { + // tslint:disable-next-line:no-any + let server: any; + let request: express.Request; + let response: express.Response; + let responseStub: sinon.SinonStub; + beforeEach(testSetup); + it('routes correctly when controller and method exist', async () => { + await routeHandler(server, request, response); + sinon.assert.called(responseStub); + expect(responseStub.firstCall.args[0]).to.match(/Hello, Janet!/); + }); + + it('throws 400 when controller does not exist', async () => { + const getStub = server.get as sinon.SinonStub; + getStub.rejects(new Error('Does not exist!')); + await routeHandler(server, request, response); + expect(response.statusCode).to.equal(400); + sinon.assert.called(responseStub); + expect(responseStub.firstCall.args[0]).to.match(/Does not exist!/); + }); + + it('throws 400 when method does not exist', async () => { + request = getRequest({ + body: { + controller: 'FakeController', + method: 'notReal', + input: { + name: 'Sad', + }, + }, + }); + await routeHandler(server, request, response); + expect(response.statusCode).to.equal(400); + sinon.assert.called(responseStub); + expect(responseStub.firstCall.args[0]).to.match( + /No method was found on controller/, + ); + }); + + it('throws 500 on unhandled error', async () => { + server.get.resolves( + new class extends FakeController { + // tslint:disable-next-line:no-any + getFoo(input: any): string { + throw new Error('>:('); + } + }(), + ); + await routeHandler(server, request, response); + expect(response.statusCode).to.equal(500); + sinon.assert.called(responseStub); + expect(responseStub.firstCall.args[0].message).to.match('>:('); + }); + function testSetup() { + server = getServer(); + request = getRequest(); + response = getResponse(); + responseStub = response.send as sinon.SinonStub; + } + }); + + function getServer() { + const server = sinon.createStubInstance(RPCServer); + server.get.resolves(FakeController); + return server; + } + function getRequest(req?: Partial) { + return Object.assign( + { + body: { + controller: 'FakeController', + method: 'getFoo', + input: { + name: 'Janet', + }, + }, + }, + req, + ); + } + + function getResponse(res?: Partial) { + const resp = {}; + resp.send = sinon.stub(); + return resp; + } + + class FakeController { + // tslint:disable-next-line:no-any + getFoo(input: any) { + return `Hello, ${input.name}!`; + } + } +}); diff --git a/packages/example-rpc-server/tsconfig.json b/packages/example-rpc-server/tsconfig.json new file mode 100644 index 000000000000..d0c8f649ba31 --- /dev/null +++ b/packages/example-rpc-server/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" + ] +}