diff --git a/packages/authentication/test/acceptance/basic-auth.ts b/packages/authentication/test/acceptance/basic-auth.ts index 16e79837a016..b733ed6c513f 100644 --- a/packages/authentication/test/acceptance/basic-auth.ts +++ b/packages/authentication/test/acceptance/basic-auth.ts @@ -158,7 +158,7 @@ describe('Basic Authentication', () => { } } // bind user defined sequence - app.sequence(MySequence); + app.httpSequence(MySequence); } function givenProviders() { diff --git a/packages/core/package.json b/packages/core/package.json index 60180848c39b..3b7d268834e8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -26,6 +26,7 @@ "@types/js-yaml": "^3.9.1", "body": "^5.1.0", "debug": "^2.6.0", + "grpc": "^1.6.0", "http-errors": "^1.6.1", "js-yaml": "^3.9.1", "lodash": "^4.17.4", diff --git a/packages/core/src/application.ts b/packages/core/src/application.ts index 2041764c727c..58cb3d5dd51c 100644 --- a/packages/core/src/application.ts +++ b/packages/core/src/application.ts @@ -22,13 +22,18 @@ import { RouteEntry, createEmptyApiSpec, parseOperationArgs, + UnaryResult, } from '.'; import {ServerRequest, ServerResponse, createServer} from 'http'; import {Component, mountComponent} from './component'; import {getControllerSpec} from './router/metadata'; import {HttpHandler} from './http-handler'; import {writeResultToResponse} from './writer'; -import {DefaultSequence, SequenceHandler, SequenceFunction} from './sequence'; +import { + DefaultSequence, + SequenceHandler as HttpSequenceHandler, + SequenceFunction, +} from './sequence'; import {RejectProvider} from './router/providers/reject'; import { FindRoute, @@ -44,6 +49,11 @@ import {InvokeMethodProvider} from './router/providers/invoke-method'; import {FindRouteProvider} from './router/providers/find-route'; import {CoreBindings} from './keys'; +import { + SequenceHandler as GrpcSequenceHandler, + DefaultSequence as DefaultGrpcSequence, +} from './grpc/sequence'; + const SequenceActions = CoreBindings.SequenceActions; // NOTE(bajtos) we cannot use `import * as cloneDeep from 'lodash/cloneDeep' @@ -54,6 +64,8 @@ const cloneDeep: (value: T) => T = require('lodash/cloneDeep'); const debug = require('debug')('loopback:core:application'); +const grpc = require('grpc'); + interface OpenApiSpecOptions { version?: string; format?: string; @@ -100,6 +112,9 @@ export class Application extends Context { this.bind(CoreBindings.HTTP_PORT).to( options.http ? options.http.port : 3000, ); + this.bind(CoreBindings.Grpc.PORT).to( + options.grpc ? options.grpc.port : 50051, + ); this.api(createEmptyApiSpec()); if (options.components) { @@ -108,7 +123,7 @@ export class Application extends Context { } } - this.sequence(options.sequence ? options.sequence : DefaultSequence); + this.httpSequence(options.sequence ? options.sequence : DefaultSequence); this.handleHttp = (req: ServerRequest, res: ServerResponse) => { try { @@ -129,6 +144,8 @@ export class Application extends Context { this.bind(SequenceActions.REJECT).toProvider(RejectProvider); this.bind(CoreBindings.GET_FROM_CONTEXT).toProvider(GetFromContextProvider); this.bind(CoreBindings.BIND_ELEMENT).toProvider(BindElementProvider); + + this.grpcSequence(DefaultGrpcSequence); } protected _handleHttpRequest( @@ -435,8 +452,12 @@ export class Application extends Context { * * @param value The sequence to invoke for each incoming request. */ - public sequence(value: Constructor) { - this.bind(CoreBindings.SEQUENCE).toClass(value); + public httpSequence(value: Constructor) { + this.bind(CoreBindings.Http.SEQUENCE).toClass(value); + } + + public grpcSequence(value: Constructor) { + this.bind(CoreBindings.Grpc.SEQUENCE).toClass(value); } /** @@ -475,13 +496,13 @@ export class Application extends Context { } } - this.sequence(SequenceFromFunction); + this.httpSequence(SequenceFromFunction); } /** * Start the application (e.g. HTTP/HTTPS servers). */ - async start(): Promise { + async _startHttp(): Promise { // Setup the HTTP handler so that we can verify the configuration // of API spec, controllers and routes at startup time. this._setupHandlerIfNeeded(); @@ -502,6 +523,123 @@ export class Application extends Context { }); } + async start(): Promise { + await Promise.all([ + this._startHttp(), + this._startGrpc(), + ]); + } + + async _startGrpc(): Promise { + debug('Setting up gRPC server'); + const server = new grpc.Server(); + let hasGrpcServices = false; + + for (const b of this.find('controllers.*')) { + const controllerName = b.key.replace(/^controllers\./, ''); + const ctor = b.valueConstructor; + if (!ctor) { + throw new Error( + `The controller ${controllerName} was not bound via .toClass()`); + } + + // tslint:disable-next-line:no-any + const spec: any = (ctor as {grpcService?: object}).grpcService; + if (!spec) { + debug( + ` skipping controller ${controllerName} - no gRPC API was specified`, + ); + continue; + } + + debug(' adding controller %s with spec %j', controllerName, spec); + for (const key in spec) { + const methodName = key; + const opSpec = spec[key]; + // FIXME(bajtos) handle the case when the controller method is using + // opSpec.originalName + + if (!ctor.prototype[methodName]) { + debug( + ' - skipping method %s - %s.%s not implemented', + opSpec.originalName, + controllerName, + methodName); + continue; + } + + debug( + ' - added %s as %s.%s', + opSpec.originalName, + controllerName, + methodName, + ); + + // TODO: support stream method types + server.register( + opSpec.path, + createUnaryHandlerFor(ctor, controllerName, methodName, this), + opSpec.responseSerialize, + opSpec.requestDeserialize, + 'unary', + ); + hasGrpcServices = true; + } + } + + function createUnaryHandlerFor( + // tslint:disable-next-line:no-any + controllerCtor: Constructor, + controllerName: string, + methodName: string, + rootContext: Context, + ) { + // tslint:disable-next-line:no-any + return function(request: any, callback: any) { + debug('gRPC invoke %s.%s(%j)', controllerName, methodName, request); + handleUnary().then( + result => callback(null, result.value, result.trailer, result.flags), + error => { + debugger; + callback(error); + }, + ); + + async function handleUnary(): Promise { + const context = new Context(rootContext); + context.bind(CoreBindings.Grpc.CONTEXT).to(context); + context.bind(CoreBindings.CONTROLLER_NAME).to(controllerName); + context.bind(CoreBindings.CONTROLLER_CLASS).to(controllerCtor); + context.bind(CoreBindings.CONTROLLER_METHOD_NAME).to(methodName); + const sequence: GrpcSequenceHandler = await context.get( + CoreBindings.Grpc.SEQUENCE, + ); + return sequence.handleUnaryCall(request); + } + }; + } + + if (!hasGrpcServices) { + debug('No gRPC services are configured - server not started.'); + return; + } + + // TODO(bajtos) Make the hostname configurable + const bindPort = await this.getSync(CoreBindings.Grpc.PORT); + const port = server.bind( + `0.0.0.0:${bindPort}`, + grpc.ServerCredentials.createInsecure(), + ); + if (!port) { + throw new Error(`Cannot start gRPC server on port ${bindPort}`); + } + debug(`GRPC server listening at port ${port}`); + this.bind('grpc.port').to(port); + + // NOTE(bajtos) Looks like gRPC server starts synchronously + server.start(); + } + protected _onUnhandledError( req: ServerRequest, res: ServerResponse, @@ -523,10 +661,15 @@ export class Application extends Context { export interface ApplicationOptions { http?: HttpConfig; + grpc?: GrpcConfig; components?: Array>; - sequence?: Constructor; + sequence?: Constructor; } export interface HttpConfig { port: number; } + +export interface GrpcConfig { + port: number; +} diff --git a/packages/core/src/grpc/README.md b/packages/core/src/grpc/README.md new file mode 100644 index 000000000000..a6abb67c377b --- /dev/null +++ b/packages/core/src/grpc/README.md @@ -0,0 +1,3 @@ +# gRPC transport for Controllers (a spike) + +This directory contains a very basic implementation of gRPC transport. The real implementation would live in its own packages directory, e.g. `packages/grpc`. diff --git a/packages/core/src/grpc/decorators/service.decorator.ts b/packages/core/src/grpc/decorators/service.decorator.ts new file mode 100644 index 000000000000..75f4993bbb25 --- /dev/null +++ b/packages/core/src/grpc/decorators/service.decorator.ts @@ -0,0 +1,13 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/core +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +// tslint:disable:no-any + +// TODO(bajtos) serviceSpec needs to be typed +export function service(serviceSpec: any) { + return function(controllerCtor: Function) { + (controllerCtor as any).grpcService = serviceSpec; + }; +} diff --git a/packages/core/src/http-handler.ts b/packages/core/src/http-handler.ts index 03c46c94c907..8626070012f9 100644 --- a/packages/core/src/http-handler.ts +++ b/packages/core/src/http-handler.ts @@ -68,7 +68,7 @@ export class HttpHandler { const requestContext = this._createRequestContext(request, response); const sequence: SequenceHandler = await requestContext.get( - CoreBindings.SEQUENCE, + CoreBindings.Http.SEQUENCE, ); await sequence.handle(parsedRequest, response); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5f37aea47ab2..161edcea0244 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -22,6 +22,16 @@ import * as HttpErrors from 'http-errors'; // http errors export {HttpErrors}; +export * from './grpc/decorators/service.decorator'; +export * from './grpc/grpc-types'; +import { + SequenceHandler as GrpcSequenceHandler, + DefaultSequence as GrpcDefaultSequence, +} from './grpc/sequence'; +export {GrpcSequenceHandler, GrpcDefaultSequence}; +// NOTE(bajtos) ^^ that's a hack for this spike so that I don't have to start +// a new grpc package yet. This will not make it into `master` branch. + // internals used by unit-tests export { ParsedRequest, diff --git a/packages/core/src/keys.ts b/packages/core/src/keys.ts index 3ee84a1d0349..d47906683f8b 100644 --- a/packages/core/src/keys.ts +++ b/packages/core/src/keys.ts @@ -7,10 +7,10 @@ export namespace CoreBindings { // application-wide bindings export const HTTP_PORT = 'http.port'; + export const GRPC_PORT = 'grpc.port'; export const HTTP_HANDLER = 'http.handler'; export const API_SPEC = 'application.apiSpec'; - export const SEQUENCE = 'sequence'; export namespace SequenceActions { export const FIND_ROUTE = 'sequence.actions.findRoute'; @@ -26,13 +26,21 @@ export namespace CoreBindings { // request-specific bindings + export const CONTROLLER_NAME = 'controller.current.name'; export const CONTROLLER_CLASS = 'controller.current.ctor'; export const CONTROLLER_METHOD_NAME = 'controller.current.operation'; export const CONTROLLER_METHOD_META = 'controller.method.meta'; export namespace Http { + export const SEQUENCE = 'http.sequence'; export const REQUEST = 'http.request'; export const RESPONSE = 'http.response'; export const CONTEXT = 'http.request.context'; } + + export namespace Grpc { + export const PORT = 'grpc.port'; + export const CONTEXT = 'grpc.request.context'; + export const SEQUENCE = 'grpc.sequence'; + } } diff --git a/packages/core/src/sequence.ts b/packages/core/src/sequence.ts index 57ca17342624..2250026e3813 100644 --- a/packages/core/src/sequence.ts +++ b/packages/core/src/sequence.ts @@ -59,7 +59,7 @@ export interface SequenceHandler { * * User can bind their own Sequence to app as shown below * ```ts - * app.bind(CoreBindings.SEQUENCE).toClass(MySequence); + * app.bind(CoreBindings.Http.SEQUENCE).toClass(MySequence); * ``` */ export class DefaultSequence implements SequenceHandler { diff --git a/packages/core/test/acceptance/bootstrapping/application.acceptance.ts b/packages/core/test/acceptance/bootstrapping/application.acceptance.ts index 262710c286d3..251f742df230 100644 --- a/packages/core/test/acceptance/bootstrapping/application.acceptance.ts +++ b/packages/core/test/acceptance/bootstrapping/application.acceptance.ts @@ -13,7 +13,7 @@ describe('Bootstrapping the application', () => { before(givenAppWithUserDefinedSequence); it('binds the `sequence` key to the user-defined sequence', async () => { - const binding = await app.get(CoreBindings.SEQUENCE); + const binding = await app.get(CoreBindings.Http.SEQUENCE); expect(binding.constructor.name).to.equal('UserDefinedSequence'); }); diff --git a/packages/core/test/acceptance/grpc/grpc.acceptance.ts b/packages/core/test/acceptance/grpc/grpc.acceptance.ts new file mode 100644 index 000000000000..08896c48c003 --- /dev/null +++ b/packages/core/test/acceptance/grpc/grpc.acceptance.ts @@ -0,0 +1,143 @@ +// Copyright IBM Corp. 2013,2017. All Rights Reserved. +// Node module: loopback +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + Application, + service, + GrpcSequenceHandler, + ServerUnaryCall, + UnaryResult, + ServerWritableStream, + ServerReadableStream, + ServerDuplexStream, + Metadata, + PlainDataObject, +} from '../../..'; +import {expect} from '@loopback/testlab'; +import * as path from 'path'; +import {Greeter, HelloRequest, HelloResponse} from './hello.proto'; + +// tslint:disable-next-line:variable-name +const MetadataCtor: new () => Metadata = require('grpc').Metadata; + +// NOTE(bajtos) There is no type definition for grpc yet, see +// https://github.com/grpc/grpc/issues/8233 +const grpc = require('grpc'); + +describe('gRPC', () => { + describe('Hello Service', () => { + const helloService = grpc.load(path.join(__dirname, 'hello.proto')).hello; + + it('provides gRPC interface for decorated controllers', async () => { + @service(helloService.Greeter.service) + class MyController implements Greeter { + hello({name}: HelloRequest): HelloResponse { + return {message: `hello ${name}`}; + } + } + + const app = givenApplication(); + app.controller(MyController); + await app.start(); + + const client = createGreeterClient(app); + const result = await client.hello({name: 'world'}); + expect(result).to.eql({message: 'hello world'}); + }); + + it('supports custom Sequence', async () => { + @service(helloService.Greeter.service) + class MyController implements Greeter { + hello({name}: HelloRequest): HelloResponse { + return {message: `hello ${name}`}; + } + } + + class MySequence implements GrpcSequenceHandler { + async handleUnaryCall(request: ServerUnaryCall): Promise { + return {value: {message: 'good bye'}}; + } + + async handleServerStreaming( + request: ServerWritableStream, + ): Promise { + throw new Error('Method not implemented.'); + } + + async handleClientStreaming( + request: ServerReadableStream, + ): Promise { + throw new Error('Method not implemented.'); + } + + async handleBiDiStreaming(request: ServerDuplexStream): Promise { + throw new Error('Method not implemented.'); + } + } + + const app = givenApplication(); + app.grpcSequence(MySequence); + app.controller(MyController); + await app.start(); + + const client = createGreeterClient(app); + const result = await client.hello({name: 'world'}); + expect(result).to.eql({message: 'good bye'}); + }); + + function givenApplication() { + return new Application({ + http: {port: 0}, + grpc: {port: 0}, + }); + } + + // TODO(bajtos) We want a generic function accepting any grpc client service + // constructor (e.g. helloService.Greeter) and returning an instance + // matching our promise/async-await based interface type + // I think this can be part of the code generator which produces + // .d.ts typings from .proto files? + function createGreeterClient(app: Application): Greeter { + const port = app.getSync('grpc.port'); + const client = new helloService.Greeter( + `localhost:${port}`, + grpc.credentials.createInsecure(), + ); + + // tslint:disable-next-line:no-any + const greeter: any = {}; + + Object.keys(client.__proto__).forEach(method => { + // tslint:disable-next-line:no-any + greeter[method] = function(request: any) { + return new Promise((resolve, reject) => { + client[method](request, callbackToPromise); + + function callbackToPromise( + err: Error & {code?: number}, + // tslint:disable-next-line:no-any + response: PlainDataObject, + ) { + if (!err) { + // TODO(bajtos) return response metadata too + resolve(response); + return; + } + + // workaround for empty message produced by gRPC client + if (!err.message && err.code) { + // TODO(bajtos) replace status codes with descriptive messages + err.message = `gRPC status ${err.code}`; + } + reject(err); + } + }); + }; + }); + + return greeter as Greeter; + } + }); +}); diff --git a/packages/core/test/acceptance/grpc/hello.proto b/packages/core/test/acceptance/grpc/hello.proto new file mode 100644 index 000000000000..096f4227fd2b --- /dev/null +++ b/packages/core/test/acceptance/grpc/hello.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +package hello; + +// The greeting service definition. +service Greeter { + // Sends a greeting + rpc hello (HelloRequest) returns (HelloReply) {} +} + +// The request message containing the user's name. +message HelloRequest { + string name = 1; +} + +// The response message containing the greetings +message HelloReply { + string message = 1; +} diff --git a/packages/core/test/acceptance/grpc/hello.proto.ts b/packages/core/test/acceptance/grpc/hello.proto.ts new file mode 100644 index 000000000000..f6b2e959ad58 --- /dev/null +++ b/packages/core/test/acceptance/grpc/hello.proto.ts @@ -0,0 +1,20 @@ +// Copyright IBM Corp. 2013,2017. All Rights Reserved. +// Node module: loopback +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {ValueOrPromise} from '@loopback/context'; + +// TODO(bajtos) Generate the following three interfaces from hello.proto + +export interface HelloRequest { + name: string; +} + +export interface HelloResponse { + message: string; +} + +export interface Greeter { + hello(request: HelloRequest): ValueOrPromise; +} diff --git a/packages/core/test/acceptance/sequence/sequence.acceptance.ts b/packages/core/test/acceptance/sequence/sequence.acceptance.ts index 30982de46c61..da12efc1c571 100644 --- a/packages/core/test/acceptance/sequence/sequence.acceptance.ts +++ b/packages/core/test/acceptance/sequence/sequence.acceptance.ts @@ -56,7 +56,7 @@ describe('Sequence', () => { } } // bind user defined sequence - app.sequence(MySequence); + app.httpSequence(MySequence); whenIMakeRequestTo(app).get('/').expect('hello world'); }); @@ -79,7 +79,7 @@ describe('Sequence', () => { } } - app.sequence(MySequence); + app.httpSequence(MySequence); return whenIMakeRequestTo(app) .get('/name') @@ -137,7 +137,7 @@ describe('Sequence', () => { } } - app.sequence(MySequence); + app.httpSequence(MySequence); app.bind('test').to('hello world'); return whenIMakeRequestTo(app).get('/').expect('hello world'); diff --git a/packages/core/test/integration/http-handler.integration.ts b/packages/core/test/integration/http-handler.integration.ts index fcabd1764099..740afc5eec94 100644 --- a/packages/core/test/integration/http-handler.integration.ts +++ b/packages/core/test/integration/http-handler.integration.ts @@ -424,7 +424,7 @@ describe('HttpHandler', () => { rootContext.bind(SequenceActions.SEND).to(writeResultToResponse); rootContext.bind(SequenceActions.REJECT).toProvider(RejectProvider); - rootContext.bind(CoreBindings.SEQUENCE).toClass(DefaultSequence); + rootContext.bind(CoreBindings.Http.SEQUENCE).toClass(DefaultSequence); function logger(err: Error, statusCode: number, req: ServerRequest) { console.error(