diff --git a/NESTJS.markdown b/NESTJS.markdown new file mode 100644 index 000000000..7e299e3b6 --- /dev/null +++ b/NESTJS.markdown @@ -0,0 +1,106 @@ +NestJS +===== +With the use of NestJS it's easy to implement an GRPC Microservice ([example](https://docs.nestjs.com/microservices/grpc)). But as you can see you have a lot of boilerplating to define your GRPC interfaces correctly. This approach works fine but is a bit tricky, because it doesn't support type checks. If you change your proto file you also need to update your interface. + +By using `ts-proto` you have strong type checks and compiler errors! + +To generate `ts` files for your `.proto` files you can use the `--ts_proto_opt=nestJs=true` option. + +### Naming convention +For each service in your `.proto` file we generate two `interfaces` on to implement in your nestjs `controller` and one for the `client`. + +The name of the `controller` interface is base on the name of the service inside the `.proto`. + +If we have to following `.proto` file: +```protobuf +syntax = "proto3"; + +package hero; + +service HeroService { + rpc FindOneHero (HeroById) returns (Hero) {} + rpc FindOneVillain (VillainById) returns (Villain) {} + rpc FindManyVillain (stream VillainById) returns (stream Villain) {} +} +``` + +The controller interface name would be `HeroServiceController`. +The client interface name would `HeroServiceClient`. + +### implementation +To implement the typescript file in your `nestjs` project you need to add the `controller` interface to your controller. We also generate a `decorator` for you controller. For example: `HeroServiceControllerMethods`, when you apply this to your controller we add all the method decorators you normally should do but doing it this way is safer. + +For the client we simply pass the `client` interface to the `client.getService();` (see below). + +> Note: Based on the `.proto` we'll generate a `const` for example `HERO_PACKAGE_NAME` and `HERO_SERVICE_NAME` this way your code breaks if you change your package or service name. (It's safer to have compiler errors than runtime errors) + +##### Controller + +```typescript +import { HeroById, Hero, HeroServiceController, VillainById, Villain, HeroServiceControllerMethods } from '../hero'; + +@Controller('hero') +// Generated decorator that applies all the @GrpcMethod and @GrpcStreamMethod to the right methods +@HeroServiceControllerMethods() +export class HeroController implements HeroServiceController { + private readonly heroes: Hero[] = [ + { id: 1, name: 'Stephenh' }, + { id: 2, name: 'Iangregsondev' } + ]; + + private readonly villains: Villain[] = [ + { id: 1, name: 'John' }, + { id: 2, name: 'Doe' } + ]; + + async findOneHero(data: HeroById): Promise { + return this.heroes.find(({ id }) => id === data.id)!; + } + + async findOneVillain(data: VillainById): Promise { + return this.villains.find(({ id }) => id === data.id)!; + } + + findManyVillain(request: Observable): Observable { + const hero$ = new Subject(); + + const onNext = (villainById: VillainById) => { + const item = this.villains.find(({ id }) => id === villainById.id); + hero$.next(item); + }; + const onComplete = () => hero$.complete(); + request.subscribe(onNext, null, onComplete); + + return hero$.asObservable(); + } +} +``` + +##### Client + +```typescript +import { HeroById, Hero, HeroServiceController, HeroesService, HERO_SERVICE_NAME, HERO_PACKAGE_NAME } from '../hero'; + +@Injectable() +export class AppService implements OnModuleInit { + private heroesService: HeroesService; + + constructor(@Inject(HERO_PACKAGE_NAME) private client: ClientGrpc) {} + + onModuleInit() { + this.heroesService = this.client.getService(HERO_SERVICE_NAME); + } + + getHero(): Observable { + return this.heroesService.findOne({ id: 1 }); + } +} +``` + +### Supported options + +* With`--ts_proto_opt=addGrpcMetadata=true`, the last argument of service methods will accept the grpc `Metadata` type, which contains additional information with the call (i.e. access tokens/etc.). + + (Requires `nestJs=true`.) + +* With `--ts_proto_opt=nestJs=true`, the defaults will change to generate [NestJS protobuf](https://docs.nestjs.com/microservices/grpc) friendly types & service interfaces that can be used in both the client-side and server-side of NestJS protobuf implementations. diff --git a/README.markdown b/README.markdown index f4d44cfd2..a671ef8a8 100644 --- a/README.markdown +++ b/README.markdown @@ -2,6 +2,32 @@ [![npm](https://img.shields.io/npm/v/ts-proto)](https://www.npmjs.com/package/ts-proto) [![CircleCI](https://circleci.com/gh/stephenh/ts-proto.svg?style=svg)](https://circleci.com/gh/stephenh/ts-proto) +# ts-proto + +> `ts-proto` transforms your `.proto` files into strong typed `typescript` files! + +## Table of contents + +- [QuickStart](#quickstart) +- [Goals](#goals) +- [Example Types](#example-types) +- [Highlights](#highlights) +- [Current Disclaimers](#current-disclaimers) +- [Auto-Batching / N+1 Prevention](#auto-batching---n-1-prevention) +- [Usage](#usage) + + [Supported options](#supported-options) + + [Only Types](#only-types) + + [NestJS Support](NESTJS.markdown) +- [Building](#building) +- [Assumptions](#assumptions) +- [Todo](#todo) +- [Typing Approach](#typing-approach) +- [OneOf Handling](#oneof-handling) +- [Primitive Types](#primitive-types) +- [Wrapper Types](#wrapper-types) +- [Number Types](#number-types) +- [Current Status of Optional Values](#current-status-of-optional-values) + Overview ======== @@ -205,18 +231,21 @@ protoc --plugin=node_modules/ts-proto/protoc-gen-ts_proto ./batching.proto -I. (Requires `nestJs=true`.) -* With `--ts_proto_opt=nestJs=true`, the defaults will change to generate [NestJS protobuf](https://docs.nestjs.com/microservices/grpc) friendly types & service interfaces that can be used in both the client-side and server-side of NestJS protobuf implementations. +* With `--ts_proto_opt=nestJs=true`, the defaults will change to generate [NestJS protobuf](https://docs.nestjs.com/microservices/grpc) friendly types & service interfaces that can be used in both the client-side and server-side of NestJS protobuf implementations. See the [nestjs readme](NESTJS.markdown) for more information and implementation examples. Specifically `outputEncodeMethods`, `outputJsonMethods`, and `outputClientImpl` will all be false, and `lowerCaseServiceMethods` will be true. Note that `addGrpcMetadata` and `returnObservable` will still be false. -### "Only Types" +### Only Types If you're looking for `ts-proto` to generate only types for your Protobuf types then passing all three of `outputEncodeMethods`, `outputJsonMethods`, and `outputClientImpl` as `false` is probably what you want, i.e.: `--ts_proto_opt=outputEncodeMethods=false,outputJsonMethods=false,outputClientImpl=false`. +### NestJS Support +We have a great way of working together with [nestjs](https://docs.nestjs.com/microservices/grpc). `ts-proto` generates `interfaces` and `decorators` for you controller, client. For more information see the [nestjs readme](NESTJS.markdown). + Building ======== diff --git a/integration/nestjs-metadata-observables/hero.bin b/integration/nestjs-metadata-observables/hero.bin index 09cbd24d1..6723ce7c7 100644 Binary files a/integration/nestjs-metadata-observables/hero.bin and b/integration/nestjs-metadata-observables/hero.bin differ diff --git a/integration/nestjs-metadata-observables/hero.proto b/integration/nestjs-metadata-observables/hero.proto index 6ccb51018..5fbd820f3 100644 --- a/integration/nestjs-metadata-observables/hero.proto +++ b/integration/nestjs-metadata-observables/hero.proto @@ -5,6 +5,7 @@ package hero; service HeroService { rpc FindOneHero (HeroById) returns (Hero) {} rpc FindOneVillain (VillainById) returns (Villain) {} + rpc FindManyVillain (stream VillainById) returns (stream Villain) {} } message HeroById { diff --git a/integration/nestjs-metadata-observables/hero.ts b/integration/nestjs-metadata-observables/hero.ts index db16a6cf1..23e2347af 100644 --- a/integration/nestjs-metadata-observables/hero.ts +++ b/integration/nestjs-metadata-observables/hero.ts @@ -1,5 +1,6 @@ import { Metadata } from 'grpc'; import { Observable } from 'rxjs'; +import { GrpcMethod, GrpcStreamMethod } from '@nestjs/microservices'; export interface HeroById { @@ -20,10 +21,40 @@ export interface Villain { name: string; } -export interface HeroService { +export interface HeroServiceController { findOneHero(request: HeroById, metadata?: Metadata): Observable; findOneVillain(request: VillainById, metadata?: Metadata): Observable; + findManyVillain(request: Observable, metadata?: Metadata): Observable; + +} + +export interface HeroServiceClient { + + findOneHero(request: HeroById, metadata?: Metadata): Observable; + + findOneVillain(request: VillainById, metadata?: Metadata): Observable; + + findManyVillain(request: Observable, metadata?: Metadata): Observable; + } + +export function HeroServiceControllerMethods() { + return function (constructor: Function) { + const grpcMethods: string[] = ['findOneHero', 'findOneVillain']; + for (const method of grpcMethods) { + const descriptor: any = Reflect.getOwnPropertyDescriptor(constructor.prototype, method); + GrpcMethod('HeroService', method)(constructor.prototype[method], method, descriptor); + } + const grpcStreamMethods: string[] = ['findManyVillain']; + for (const method of grpcStreamMethods) { + const descriptor: any = Reflect.getOwnPropertyDescriptor(constructor.prototype, method); + GrpcStreamMethod('HeroService', method)(constructor.prototype[method], method, descriptor); + } + } +} + +export const HERO_PACKAGE_NAME = 'hero' +export const HERO_SERVICE_NAME = 'HeroService' \ No newline at end of file diff --git a/integration/nestjs-metadata-observables/sample-service.ts b/integration/nestjs-metadata-observables/sample-service.ts index 7eb2eda29..0a0c8f322 100644 --- a/integration/nestjs-metadata-observables/sample-service.ts +++ b/integration/nestjs-metadata-observables/sample-service.ts @@ -1,8 +1,8 @@ -import { HeroService, HeroById, Hero, Villain, VillainById } from './hero'; -import { Observable, of } from 'rxjs'; +import { HeroServiceController, HeroById, Hero, Villain, VillainById } from './hero'; +import { Observable, of, Subject } from 'rxjs'; import { Metadata } from 'grpc'; -export class SampleService implements HeroService { +export class SampleService implements HeroServiceController { findOneHero(request: HeroById, metadata?: Metadata): Observable { return of({ id: 1, name: 'test' }); } @@ -10,4 +10,16 @@ export class SampleService implements HeroService { findOneVillain(request: VillainById, metadata?: Metadata): Observable { return of({ id: 1, name: 'test' }); } + + findManyVillain(request: Observable, metadata?: Metadata): Observable { + const hero$ = new Subject(); + + const onNext = (villainById: VillainById) => { + hero$.next({ id: 1, name: 'test' }); + }; + const onComplete = () => hero$.complete(); + request.subscribe(onNext, null, onComplete); + + return hero$.asObservable(); + } } diff --git a/integration/nestjs-metadata/hero.bin b/integration/nestjs-metadata/hero.bin index 09cbd24d1..6723ce7c7 100644 Binary files a/integration/nestjs-metadata/hero.bin and b/integration/nestjs-metadata/hero.bin differ diff --git a/integration/nestjs-metadata/hero.proto b/integration/nestjs-metadata/hero.proto index 6ccb51018..5fbd820f3 100644 --- a/integration/nestjs-metadata/hero.proto +++ b/integration/nestjs-metadata/hero.proto @@ -5,6 +5,7 @@ package hero; service HeroService { rpc FindOneHero (HeroById) returns (Hero) {} rpc FindOneVillain (VillainById) returns (Villain) {} + rpc FindManyVillain (stream VillainById) returns (stream Villain) {} } message HeroById { diff --git a/integration/nestjs-metadata/hero.ts b/integration/nestjs-metadata/hero.ts index 10d45e1ea..7567872fe 100644 --- a/integration/nestjs-metadata/hero.ts +++ b/integration/nestjs-metadata/hero.ts @@ -1,4 +1,6 @@ import { Metadata } from 'grpc'; +import { Observable } from 'rxjs'; +import { GrpcMethod, GrpcStreamMethod } from '@nestjs/microservices'; export interface HeroById { @@ -19,10 +21,40 @@ export interface Villain { name: string; } -export interface HeroService { +export interface HeroServiceController { - findOneHero(request: HeroById, metadata?: Metadata): Promise; + findOneHero(request: HeroById, metadata?: Metadata): Promise | Observable | Hero; - findOneVillain(request: VillainById, metadata?: Metadata): Promise; + findOneVillain(request: VillainById, metadata?: Metadata): Promise | Observable | Villain; + + findManyVillain(request: Observable, metadata?: Metadata): Observable; + +} + +export interface HeroServiceClient { + + findOneHero(request: HeroById, metadata?: Metadata): Observable; + + findOneVillain(request: VillainById, metadata?: Metadata): Observable; + + findManyVillain(request: Observable, metadata?: Metadata): Observable; } + +export function HeroServiceControllerMethods() { + return function (constructor: Function) { + const grpcMethods: string[] = ['findOneHero', 'findOneVillain']; + for (const method of grpcMethods) { + const descriptor: any = Reflect.getOwnPropertyDescriptor(constructor.prototype, method); + GrpcMethod('HeroService', method)(constructor.prototype[method], method, descriptor); + } + const grpcStreamMethods: string[] = ['findManyVillain']; + for (const method of grpcStreamMethods) { + const descriptor: any = Reflect.getOwnPropertyDescriptor(constructor.prototype, method); + GrpcStreamMethod('HeroService', method)(constructor.prototype[method], method, descriptor); + } + } +} + +export const HERO_PACKAGE_NAME = 'hero' +export const HERO_SERVICE_NAME = 'HeroService' \ No newline at end of file diff --git a/integration/nestjs-metadata/sample-service.ts b/integration/nestjs-metadata/sample-service.ts index a2f2615bb..ff285546e 100644 --- a/integration/nestjs-metadata/sample-service.ts +++ b/integration/nestjs-metadata/sample-service.ts @@ -1,7 +1,8 @@ -import { HeroService, HeroById, Hero, Villain, VillainById } from './hero'; +import { HeroServiceController, HeroById, Hero, Villain, VillainById } from './hero'; import { Metadata } from 'grpc'; +import { Observable, Subject } from 'rxjs'; -export class SampleService implements HeroService { +export class SampleService implements HeroServiceController { findOneHero(request: HeroById, metadata?: Metadata): Promise { return Promise.resolve({ id: 1, name: 'test' }); } @@ -9,4 +10,16 @@ export class SampleService implements HeroService { findOneVillain(request: VillainById, metadata?: Metadata): Promise { return Promise.resolve({ id: 1, name: 'test' }); } + + findManyVillain(request: Observable): Observable { + const hero$ = new Subject(); + + const onNext = (villainById: VillainById) => { + hero$.next({ id: 1, name: 'test' }); + }; + const onComplete = () => hero$.complete(); + request.subscribe(onNext, null, onComplete); + + return hero$.asObservable(); + } } diff --git a/integration/nestjs-simple-observables/hero.bin b/integration/nestjs-simple-observables/hero.bin index 09cbd24d1..6723ce7c7 100644 Binary files a/integration/nestjs-simple-observables/hero.bin and b/integration/nestjs-simple-observables/hero.bin differ diff --git a/integration/nestjs-simple-observables/hero.proto b/integration/nestjs-simple-observables/hero.proto index 6ccb51018..5fbd820f3 100644 --- a/integration/nestjs-simple-observables/hero.proto +++ b/integration/nestjs-simple-observables/hero.proto @@ -5,6 +5,7 @@ package hero; service HeroService { rpc FindOneHero (HeroById) returns (Hero) {} rpc FindOneVillain (VillainById) returns (Villain) {} + rpc FindManyVillain (stream VillainById) returns (stream Villain) {} } message HeroById { diff --git a/integration/nestjs-simple-observables/hero.ts b/integration/nestjs-simple-observables/hero.ts index 91de23b05..34f3cccd5 100644 --- a/integration/nestjs-simple-observables/hero.ts +++ b/integration/nestjs-simple-observables/hero.ts @@ -1,4 +1,5 @@ import { Observable } from 'rxjs'; +import { GrpcMethod, GrpcStreamMethod } from '@nestjs/microservices'; export interface HeroById { @@ -19,10 +20,40 @@ export interface Villain { name: string; } -export interface HeroService { +export interface HeroServiceController { findOneHero(request: HeroById): Observable; findOneVillain(request: VillainById): Observable; + findManyVillain(request: Observable): Observable; + +} + +export interface HeroServiceClient { + + findOneHero(request: HeroById): Observable; + + findOneVillain(request: VillainById): Observable; + + findManyVillain(request: Observable): Observable; + } + +export function HeroServiceControllerMethods() { + return function (constructor: Function) { + const grpcMethods: string[] = ['findOneHero', 'findOneVillain']; + for (const method of grpcMethods) { + const descriptor: any = Reflect.getOwnPropertyDescriptor(constructor.prototype, method); + GrpcMethod('HeroService', method)(constructor.prototype[method], method, descriptor); + } + const grpcStreamMethods: string[] = ['findManyVillain']; + for (const method of grpcStreamMethods) { + const descriptor: any = Reflect.getOwnPropertyDescriptor(constructor.prototype, method); + GrpcStreamMethod('HeroService', method)(constructor.prototype[method], method, descriptor); + } + } +} + +export const HERO_PACKAGE_NAME = 'hero' +export const HERO_SERVICE_NAME = 'HeroService' \ No newline at end of file diff --git a/integration/nestjs-simple-observables/sample-service.ts b/integration/nestjs-simple-observables/sample-service.ts index 23c9917bc..6a86d028c 100644 --- a/integration/nestjs-simple-observables/sample-service.ts +++ b/integration/nestjs-simple-observables/sample-service.ts @@ -1,12 +1,24 @@ -import { HeroService, HeroById, Hero, Villain, VillainById } from './hero'; -import { Observable, of } from 'rxjs'; +import { HeroServiceController, HeroById, Hero, Villain, VillainById } from './hero'; +import { Observable, of, Subject } from 'rxjs'; -export class SampleService implements HeroService { +export class SampleService implements HeroServiceController { findOneHero(request: HeroById): Observable { return of({ id: 1, name: 'test' }); } - + findOneVillain(request: VillainById): Observable { return of({ id: 1, name: 'test' }); } + + findManyVillain(request: Observable): Observable { + const hero$ = new Subject(); + + const onNext = (villainById: VillainById) => { + hero$.next({ id: 1, name: 'test' }); + }; + const onComplete = () => hero$.complete(); + request.subscribe(onNext, null, onComplete); + + return hero$.asObservable(); + } } diff --git a/integration/nestjs-simple/google/protobuf/empty.ts b/integration/nestjs-simple/google/protobuf/empty.ts new file mode 100644 index 000000000..985664c5a --- /dev/null +++ b/integration/nestjs-simple/google/protobuf/empty.ts @@ -0,0 +1,16 @@ + +/** + * A generic empty message that you can re-use to avoid defining duplicated + * empty messages in your APIs. A typical example is to use it as the request + * or the response type of an API method. For instance: + * + * service Foo { + * rpc Bar(google.protobuf.Empty) returns (google.protobuf.Empty); + * } + * + * The JSON representation for `Empty` is empty JSON object `{}`. + */ +export interface Empty { +} + +export const GOOGLE.PROTOBUF_PACKAGE_NAME = 'google.protobuf' \ No newline at end of file diff --git a/integration/nestjs-simple/hero.bin b/integration/nestjs-simple/hero.bin index 09cbd24d1..b3d091534 100644 Binary files a/integration/nestjs-simple/hero.bin and b/integration/nestjs-simple/hero.bin differ diff --git a/integration/nestjs-simple/hero.proto b/integration/nestjs-simple/hero.proto index 6ccb51018..4b6564286 100644 --- a/integration/nestjs-simple/hero.proto +++ b/integration/nestjs-simple/hero.proto @@ -1,10 +1,14 @@ syntax = "proto3"; +import "google/protobuf/empty.proto"; + package hero; service HeroService { + rpc AddOneHero (Hero) returns (google.protobuf.Empty) {} rpc FindOneHero (HeroById) returns (Hero) {} rpc FindOneVillain (VillainById) returns (Villain) {} + rpc FindManyVillain (stream VillainById) returns (stream Villain) {} } message HeroById { diff --git a/integration/nestjs-simple/hero.ts b/integration/nestjs-simple/hero.ts index c632d25b8..5ce19ffa5 100644 --- a/integration/nestjs-simple/hero.ts +++ b/integration/nestjs-simple/hero.ts @@ -1,3 +1,6 @@ +import { Observable } from 'rxjs'; +import { Empty } from './google/protobuf/empty'; +import { GrpcMethod, GrpcStreamMethod } from '@nestjs/microservices'; export interface HeroById { @@ -18,10 +21,44 @@ export interface Villain { name: string; } -export interface HeroService { +export interface HeroServiceController { - findOneHero(request: HeroById): Promise; + addOneHero(request: Hero): void; - findOneVillain(request: VillainById): Promise; + findOneHero(request: HeroById): Promise | Observable | Hero; + + findOneVillain(request: VillainById): Promise | Observable | Villain; + + findManyVillain(request: Observable): Observable; + +} + +export interface HeroServiceClient { + + addOneHero(request: Hero): Observable; + + findOneHero(request: HeroById): Observable; + + findOneVillain(request: VillainById): Observable; + + findManyVillain(request: Observable): Observable; } + +export function HeroServiceControllerMethods() { + return function (constructor: Function) { + const grpcMethods: string[] = ['addOneHero', 'findOneHero', 'findOneVillain']; + for (const method of grpcMethods) { + const descriptor: any = Reflect.getOwnPropertyDescriptor(constructor.prototype, method); + GrpcMethod('HeroService', method)(constructor.prototype[method], method, descriptor); + } + const grpcStreamMethods: string[] = ['findManyVillain']; + for (const method of grpcStreamMethods) { + const descriptor: any = Reflect.getOwnPropertyDescriptor(constructor.prototype, method); + GrpcStreamMethod('HeroService', method)(constructor.prototype[method], method, descriptor); + } + } +} + +export const HERO_PACKAGE_NAME = 'hero' +export const HERO_SERVICE_NAME = 'HeroService' \ No newline at end of file diff --git a/integration/nestjs-simple/nestjs-project/app.module.ts b/integration/nestjs-simple/nestjs-project/app.module.ts new file mode 100644 index 000000000..81dad2f3d --- /dev/null +++ b/integration/nestjs-simple/nestjs-project/app.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { HeroController } from './hero.controller'; +import { ClientsModule, Transport } from '@nestjs/microservices'; +import { join } from 'path'; +import { HERO_PACKAGE_NAME } from '../hero'; + +@Module({ + imports: [ + ClientsModule.register([ + { + name: HERO_PACKAGE_NAME, + transport: Transport.GRPC, + options: { + url: '0.0.0.0:8080', + package: HERO_PACKAGE_NAME, + protoPath: join(__dirname, '../hero.proto'), + }, + }, + ]), + ], + controllers: [HeroController] +}) +export class AppModule {} \ No newline at end of file diff --git a/integration/nestjs-simple/nestjs-project/hero.controller.ts b/integration/nestjs-simple/nestjs-project/hero.controller.ts new file mode 100644 index 000000000..f7b85ea0d --- /dev/null +++ b/integration/nestjs-simple/nestjs-project/hero.controller.ts @@ -0,0 +1,42 @@ +import { Controller } from '@nestjs/common'; +import { Observable, Subject } from 'rxjs'; +import { HeroById, Hero, HeroServiceController, VillainById, Villain, HeroServiceControllerMethods } from '../hero'; + +@Controller('hero') +@HeroServiceControllerMethods() +export class HeroController implements HeroServiceController { + private readonly heroes: Hero[] = [ + { id: 1, name: 'Stephenh' }, + { id: 2, name: 'Iangregsondev' } + ]; + + private readonly villains: Villain[] = [ + { id: 1, name: 'John' }, + { id: 2, name: 'Doe' } + ]; + + addOneHero(request: Hero) { + this.heroes.push(request); + } + + async findOneHero(data: HeroById): Promise { + return this.heroes.find(({ id }) => id === data.id)!; + } + + async findOneVillain(data: VillainById): Promise { + return this.villains.find(({ id }) => id === data.id)!; + } + + findManyVillain(request: Observable): Observable { + const hero$ = new Subject(); + + const onNext = (villainById: VillainById) => { + const item = this.villains.find(({ id }) => id === villainById.id); + hero$.next(item); + }; + const onComplete = () => hero$.complete(); + request.subscribe(onNext, null, onComplete); + + return hero$.asObservable(); + } +} diff --git a/integration/nestjs-simple/nestjs-project/main.ts b/integration/nestjs-simple/nestjs-project/main.ts new file mode 100644 index 000000000..ac853b2e8 --- /dev/null +++ b/integration/nestjs-simple/nestjs-project/main.ts @@ -0,0 +1,18 @@ +import { NestFactory } from '@nestjs/core'; +import { MicroserviceOptions, Transport } from '@nestjs/microservices'; +import { join } from 'path'; +import { AppModule } from './app.module'; +import { HERO_PACKAGE_NAME } from '../hero'; + +export async function createApp() { + const app = await NestFactory.createMicroservice(AppModule, { + transport: Transport.GRPC, + options: { + url: '0.0.0.0:8080', + package: HERO_PACKAGE_NAME, + protoPath: join(__dirname, '../hero.proto'), + }, + }); + + return app; +} \ No newline at end of file diff --git a/integration/nestjs-simple/nestjs-simple-test.ts b/integration/nestjs-simple/nestjs-simple-test.ts index 1327252d9..9eaa64f3b 100644 --- a/integration/nestjs-simple/nestjs-simple-test.ts +++ b/integration/nestjs-simple/nestjs-simple-test.ts @@ -1,4 +1,9 @@ import { SampleService } from './sample-service'; +import { createApp } from './nestjs-project/main'; +import { INestMicroservice } from '@nestjs/common'; +import { ClientGrpc } from '@nestjs/microservices'; +import { HeroServiceClient, VillainById, Villain, HERO_SERVICE_NAME, HERO_PACKAGE_NAME } from './hero'; +import { Subject } from 'rxjs'; describe('nestjs-simple-test', () => { it('compiles', () => { @@ -6,3 +11,66 @@ describe('nestjs-simple-test', () => { expect(service).not.toBeUndefined(); }); }); + +describe('nestjs-simple-test nestjs', () => { + let app: INestMicroservice; + let client: ClientGrpc; + let heroService: HeroServiceClient; + + beforeAll(async () => { + app = await createApp(); + client = app.get(HERO_PACKAGE_NAME); + heroService = client.getService(HERO_SERVICE_NAME); + await app.listenAsync(); + }); + + afterAll(async () => { + await app.close(); + }); + + it('should get grpc client', async () => { + expect(client).not.toBeUndefined(); + }); + + it('should get heroService', async () => { + expect(heroService).not.toBeUndefined(); + }); + + it('should addOneHero', async () => { + const emptyResponse = await heroService.addOneHero({ id: 3, name: 'Toon' }).toPromise(); + expect(emptyResponse).toEqual({}); + }); + + + it('should findOneHero', async () => { + const hero = await heroService.findOneHero({ id: 1 }).toPromise(); + expect(hero).toEqual({ id: 1, name: 'Stephenh' }); + }); + + it('should findOneVillain', async () => { + const villain = await heroService.findOneVillain({ id: 1 }).toPromise(); + expect(villain).toEqual({ id: 1, name: 'John' }); + }); + + it('should findManyVillain', done => { + const villainIdSubject = new Subject(); + const villains: Villain[] = []; + + heroService.findManyVillain(villainIdSubject.asObservable()).subscribe({ + next: villain => { + villains.push(villain); + }, + complete: () => { + expect(villains).toEqual([ + { id: 1, name: 'John' }, + { id: 2, name: 'Doe' } + ]); + done(); + } + }); + + villainIdSubject.next({ id: 1 }); + villainIdSubject.next({ id: 2 }); + villainIdSubject.complete(); + }); +}); diff --git a/integration/nestjs-simple/sample-service.ts b/integration/nestjs-simple/sample-service.ts index d75de80ae..afe82b21b 100644 --- a/integration/nestjs-simple/sample-service.ts +++ b/integration/nestjs-simple/sample-service.ts @@ -1,6 +1,10 @@ -import { HeroService, HeroById, Hero, Villain, VillainById } from './hero'; +import { HeroServiceController, HeroById, Hero, Villain, VillainById } from './hero'; +import { Observable, Subject } from 'rxjs'; + +export class SampleService implements HeroServiceController { + + addOneHero(request: Hero): void {} -export class SampleService implements HeroService { findOneHero(request: HeroById): Promise { return Promise.resolve({ id: 1, name: 'test' }); } @@ -8,4 +12,16 @@ export class SampleService implements HeroService { findOneVillain(request: VillainById): Promise { return Promise.resolve({ id: 1, name: 'test' }); } + + findManyVillain(request: Observable): Observable { + const hero$ = new Subject(); + + const onNext = (villainById: VillainById) => { + hero$.next({ id: 1, name: 'test' }); + }; + const onComplete = () => hero$.complete(); + request.subscribe(onNext, null, onComplete); + + return hero$.asObservable(); + } } diff --git a/package.json b/package.json index a2ee26cf5..a0515daba 100644 --- a/package.json +++ b/package.json @@ -17,15 +17,21 @@ "author": "", "license": "ISC", "devDependencies": { + "@grpc/proto-loader": "^0.5.4", + "@nestjs/common": "^7.0.9", + "@nestjs/core": "^7.0.9", + "@nestjs/microservices": "^7.0.9", "@types/jest": "^24.0.11", "@types/node": "^10.7.0", "grpc": "^1.24.2", "jest": "^25.1.0", "prettier": "^1.16.4", + "reflect-metadata": "^0.1.13", "rxjs": "^6.5.5", "ts-jest": "^25.2.1", "ts-node": "^8.3.0", - "typescript": "^3.9.0-beta" + "typescript": "^3.9.0-beta", + "uglify-js": "^3.9.2" }, "dependencies": { "@types/object-hash": "^1.3.0", diff --git a/src/main.ts b/src/main.ts index 448bd8a0b..ea53d82d6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,7 +8,8 @@ import { PropertySpec, TypeName, TypeNames, - Union + Union, + DecoratorSpec } from 'ts-poet'; import { google } from '../build/pbjs'; import { @@ -31,7 +32,8 @@ import { packedType, toReaderCall, toTypeName, - TypeMap + TypeMap, + isEmptyType } from './types'; import { asSequence } from 'sequency'; import SourceInfo, { Fields } from './sourceInfo'; @@ -42,6 +44,7 @@ import FileDescriptorProto = google.protobuf.FileDescriptorProto; import EnumDescriptorProto = google.protobuf.EnumDescriptorProto; import ServiceDescriptorProto = google.protobuf.ServiceDescriptorProto; import MethodDescriptorProto = google.protobuf.MethodDescriptorProto; +import { Encloser } from 'ts-poet/build/FunctionSpec'; const dataloader = TypeNames.anyType('DataLoader*dataloader'); @@ -100,6 +103,11 @@ export function generateFile(typeMap: TypeMap, fileDesc: FileDescriptorProto, pa } ); + // If nestJs=true export [package]_PACKAGE_NAME and [service]_SERVICE_NAME const + if(options.nestJs) { + file = file.addCode(CodeBlock.empty().add(`export const %L = '%L'`, `${camelToSnake(fileDesc.package)}_PACKAGE_NAME`, fileDesc.package)); + } + if (options.outputEncodeMethods || options.outputJsonMethods) { // then add the encoder/decoder/base instance visit( @@ -135,7 +143,25 @@ export function generateFile(typeMap: TypeMap, fileDesc: FileDescriptorProto, pa } visitServices(fileDesc, sourceInfo, (serviceDesc, sInfo) => { - file = file.addInterface(generateService(typeMap, fileDesc, sInfo, serviceDesc, options)); + file = file.addInterface( + options.nestJs + ? generateNestjsServiceController(typeMap, fileDesc, sInfo, serviceDesc, options) + : generateService(typeMap, fileDesc, sInfo, serviceDesc, options) + ); + if (options.nestJs) { + // generate nestjs grpc client interface + file = file.addInterface(generateNestjsServiceClient(typeMap, fileDesc, sInfo, serviceDesc, options)); + + // generate nestjs grpc service controller decorator + file = file.addFunction(generateNestjsGrpcServiceMethodsDecorator(serviceDesc, options)); + + let serviceConstName = `${camelToSnake(serviceDesc.name)}_NAME`; + if(!serviceDesc.name.toLowerCase().endsWith('service')){ + serviceConstName = `${camelToSnake(serviceDesc.name)}_SERVICE_NAME`; + } + + file = file.addCode(CodeBlock.empty().add(`export const %L = '%L';`, serviceConstName, serviceDesc.name)); + } file = !options.outputClientImpl ? file : file.addClass(generateServiceClientImpl(typeMap, fileDesc, serviceDesc, options)); @@ -971,9 +997,8 @@ function generateService( let index = 0; for (const methodDesc of serviceDesc.method) { - if (options.lowerCaseServiceMethods) { - methodDesc.name = camelCase(methodDesc.name) + methodDesc.name = camelCase(methodDesc.name); } let requestFn = FunctionSpec.create(methodDesc.name); @@ -987,11 +1012,11 @@ function generateService( // Use metadata as last argument for interface only configuration if (options.addGrpcMetadata) { - requestFn = requestFn.addParameter('metadata?', "Metadata@grpc"); + requestFn = requestFn.addParameter('metadata?', 'Metadata@grpc'); } - // Return observable for interface only configuration and passing returnObservable=true - if (options.returnObservable) { + // Return observable for interface only configuration, passing returnObservable=true and methodDesc.serverStreaming=true + if (options.returnObservable || methodDesc.serverStreaming) { requestFn = requestFn.returns(responseObservable(typeMap, methodDesc)); } else { requestFn = requestFn.returns(responsePromise(typeMap, methodDesc)); @@ -1039,7 +1064,6 @@ function generateRegularRpcMethod( serviceDesc: google.protobuf.ServiceDescriptorProto, methodDesc: google.protobuf.MethodDescriptorProto ) { - let requestFn = FunctionSpec.create(methodDesc.name); if (options.useContext) { requestFn = requestFn.addParameter('ctx', TypeNames.typeVariable('Context')); @@ -1106,6 +1130,178 @@ function generateServiceClientImpl( return client; } +function generateNestjsServiceController( + typeMap: TypeMap, + fileDesc: FileDescriptorProto, + sourceInfo: SourceInfo, + serviceDesc: ServiceDescriptorProto, + options: Options +): InterfaceSpec { + let service = InterfaceSpec.create(`${serviceDesc.name}Controller`).addModifiers(Modifier.EXPORT); + if (options.useContext) { + service = service.addTypeVariable(contextTypeVar); + } + maybeAddComment(sourceInfo, text => (service = service.addJavadoc(text))); + + let index = 0; + for (const methodDesc of serviceDesc.method) { + if (options.lowerCaseServiceMethods) { + methodDesc.name = camelCase(methodDesc.name); + } + + let requestFn = FunctionSpec.create(methodDesc.name); + if (options.useContext) { + requestFn = requestFn.addParameter('ctx', TypeNames.typeVariable('Context')); + } + const info = sourceInfo.lookup(Fields.service.method, index++); + maybeAddComment(info, text => (requestFn = requestFn.addJavadoc(text))); + + requestFn = requestFn.addParameter('request', requestType(typeMap, methodDesc)); + + // Use metadata as last argument for interface only configuration + if (options.addGrpcMetadata) { + requestFn = requestFn.addParameter('metadata?', 'Metadata@grpc'); + } + + // Return observable for interface only configuration, passing returnObservable=true and methodDesc.serverStreaming=true + if (isEmptyType(methodDesc.outputType)) { + requestFn = requestFn.returns(TypeNames.anyType('void')); + }else if (options.returnObservable || methodDesc.serverStreaming) { + requestFn = requestFn.returns(responseObservable(typeMap, methodDesc)); + } else { + // generate nestjs union type + requestFn = requestFn.returns( + TypeNames.unionType( + responsePromise(typeMap, methodDesc), + responseObservable(typeMap, methodDesc), + responseType(typeMap, methodDesc) + ) + ); + } + + service = service.addFunction(requestFn); + + if (options.useContext) { + const batchMethod = detectBatchMethod(typeMap, fileDesc, serviceDesc, methodDesc, options); + if (batchMethod) { + const name = batchMethod.methodDesc.name.replace('Batch', 'Get'); + let batchFn = FunctionSpec.create(name); + if (options.useContext) { + batchFn = batchFn.addParameter('ctx', TypeNames.typeVariable('Context')); + } + batchFn = batchFn.addParameter(singular(batchMethod.inputFieldName), batchMethod.inputType); + batchFn = batchFn.returns(TypeNames.PROMISE.param(batchMethod.outputType)); + service = service.addFunction(batchFn); + } + } + } + return service; +} + +function generateNestjsServiceClient( + typeMap: TypeMap, + fileDesc: FileDescriptorProto, + sourceInfo: SourceInfo, + serviceDesc: ServiceDescriptorProto, + options: Options +): InterfaceSpec { + let service = InterfaceSpec.create(`${serviceDesc.name}Client`).addModifiers(Modifier.EXPORT); + if (options.useContext) { + service = service.addTypeVariable(contextTypeVar); + } + maybeAddComment(sourceInfo, text => (service = service.addJavadoc(text))); + + let index = 0; + for (const methodDesc of serviceDesc.method) { + if (options.lowerCaseServiceMethods) { + methodDesc.name = camelCase(methodDesc.name); + } + + let requestFn = FunctionSpec.create(methodDesc.name); + if (options.useContext) { + requestFn = requestFn.addParameter('ctx', TypeNames.typeVariable('Context')); + } + const info = sourceInfo.lookup(Fields.service.method, index++); + maybeAddComment(info, text => (requestFn = requestFn.addJavadoc(text))); + + requestFn = requestFn.addParameter('request', requestType(typeMap, methodDesc)); + + // Use metadata as last argument for interface only configuration + if (options.addGrpcMetadata) { + requestFn = requestFn.addParameter('metadata?', 'Metadata@grpc'); + } + + // Return observable since nestjs client always returns an Observable + requestFn = requestFn.returns(responseObservable(typeMap, methodDesc)); + + service = service.addFunction(requestFn); + + if (options.useContext) { + const batchMethod = detectBatchMethod(typeMap, fileDesc, serviceDesc, methodDesc, options); + if (batchMethod) { + const name = batchMethod.methodDesc.name.replace('Batch', 'Get'); + let batchFn = FunctionSpec.create(name); + if (options.useContext) { + batchFn = batchFn.addParameter('ctx', TypeNames.typeVariable('Context')); + } + batchFn = batchFn.addParameter(singular(batchMethod.inputFieldName), batchMethod.inputType); + batchFn = batchFn.returns(TypeNames.PROMISE.param(batchMethod.outputType)); + service = service.addFunction(batchFn); + } + } + } + return service; +} + +function generateNestjsGrpcServiceMethodsDecorator( + serviceDesc: ServiceDescriptorProto, + options: Options, +): FunctionSpec { + let grpcServiceDecorator = FunctionSpec.create(`${serviceDesc.name}ControllerMethods`).addModifiers(Modifier.EXPORT); + + const grpcMethods = serviceDesc.method + .filter(m => !m.serverStreaming && !m.clientStreaming) + .map(m => `'${options.lowerCaseServiceMethods ? camelCase(m.name) : m.name}'`) + .join(', '); + + const grpcStreamMethods = serviceDesc.method + .filter(m => m.serverStreaming || m.clientStreaming) + .map(m => `'${options.lowerCaseServiceMethods ? camelCase(m.name) : m.name}'`) + .join(', '); + + const grpcMethodType = TypeNames.importedType('GrpcMethod@@nestjs/microservices'); + const grpcStreamMethodType = TypeNames.importedType('GrpcStreamMethod@@nestjs/microservices'); + + let decoratorFunction = FunctionSpec.createCallable().addParameter('constructor', TypeNames.typeVariable('Function')) + + // add loop for applying @GrpcMethod decorators to functions + decoratorFunction = generateGrpcMethodDecoratorLoop(decoratorFunction, serviceDesc, 'grpcMethods', grpcMethods, grpcMethodType); + + // add loop for applying @GrpcStreamMethod decorators to functions + decoratorFunction = generateGrpcMethodDecoratorLoop(decoratorFunction, serviceDesc, 'grpcStreamMethods', grpcStreamMethods, grpcStreamMethodType); + + const body = CodeBlock.empty().add('return function %F', decoratorFunction); + + grpcServiceDecorator = grpcServiceDecorator.addCodeBlock(body); + + return grpcServiceDecorator; +} + +function generateGrpcMethodDecoratorLoop( + decoratorFunction: FunctionSpec, + serviceDesc: ServiceDescriptorProto, + grpcMethodsName: string, + grpcMethods: string, + grpcType: any +): FunctionSpec { + return decoratorFunction + .addStatement('const %L: string[] = [%L]', grpcMethodsName, grpcMethods) + .beginControlFlow('for (const method of %L)', grpcMethodsName) + .addStatement(`const %L: any = %L`, 'descriptor', `Reflect.getOwnPropertyDescriptor(constructor.prototype, method)`) + .addStatement(`%T('${serviceDesc.name}', method)(constructor.prototype[method], method, descriptor)`, grpcType) + .endControlFlow(); +} + function detectBatchMethod( typeMap: TypeMap, fileDesc: FileDescriptorProto, @@ -1270,6 +1466,9 @@ function generateDataLoadersType(): InterfaceSpec { } function requestType(typeMap: TypeMap, methodDesc: MethodDescriptorProto): TypeName { + if (methodDesc.clientStreaming) { + return TypeNames.anyType('Observable@rxjs').param(messageToTypeName(typeMap, methodDesc.inputType)); + } return messageToTypeName(typeMap, methodDesc.inputType); } @@ -1282,7 +1481,7 @@ function responsePromise(typeMap: TypeMap, methodDesc: MethodDescriptorProto): T } function responseObservable(typeMap: TypeMap, methodDesc: MethodDescriptorProto): TypeName { - return TypeNames.anyType("Observable@rxjs").param(responseType(typeMap, methodDesc)); + return TypeNames.anyType('Observable@rxjs').param(responseType(typeMap, methodDesc)); } // function generateOneOfProperty(typeMap: TypeMap, name: string, fields: FieldDescriptorProto[]): PropertySpec { @@ -1304,6 +1503,12 @@ function maybeSnakeToCamel(s: string, options: Options): string { } } +function camelToSnake(s: string): string { + return s.replace(/[\w]([A-Z])/g, function(m) { + return m[0] + "_" + m[1]; + }).toUpperCase(); +} + function capitalize(s: string): string { return s.substring(0, 1).toUpperCase() + s.substring(1); } diff --git a/src/types.ts b/src/types.ts index 908e74412..74d6cbab6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -284,6 +284,10 @@ export function isValueType(field: FieldDescriptorProto): boolean { return field.typeName in valueTypes; } +export function isEmptyType(typeName: string): boolean { + return typeName === '.google.protobuf.Empty'; +} + /** Maps `.some_proto_namespace.Message` to a TypeName. */ export function messageToTypeName(typeMap: TypeMap, protoType: string, keepValueType: boolean = false): TypeName { // Watch for the wrapper types `.google.protobuf.StringValue` and map to `string | undefined` diff --git a/tsconfig.json b/tsconfig.json index 084e25d1a..c74fef3c8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,8 @@ "module": "commonjs", "strict": true, "outDir": "build", - "skipLibCheck": true + "skipLibCheck": true, + "experimentalDecorators": true }, "include": ["src"] }