Repository: stephenh/ts-proto (ts)

Issue:
Allow NestJS to leverage Generic definitions
More a question than anything.  Is it possible to have `nestJs=true` not ignore the `outputServices` parameter?  Or, is there a specific option that will give me the portion of `genericDefinitions` that has the "definition" which looks like 
```typescript
export type SomeServieDefinition = typeof SomeServiceDefintion;

export const SomeServicesDefintition = {
  name: 'SomeService',
  fullName: '....',
  methods: {
    ....
  }
}
``` 

We have a utility that we wrote that takes the output encoders/decoders and uses the definition above to adjust the output definitions from `proto-loader` so that Nest is able to leverage the encoders and decoders from `ts-proto`.  I've tinkered with a lot of the options and I wasn't able get my desired result.  I was able to determine that the output I want comes from the `genericDefintions` option, however.

Pull request description:
fix: Allow other services with nestJs.
Fixes #972 

Patch:
diff --git a/integration/nestjs-metadata-grpc-js/hero.ts b/integration/nestjs-metadata-grpc-js/hero.ts
index 54483ffd..0d46ee1c 100644
--- a/integration/nestjs-metadata-grpc-js/hero.ts
+++ b/integration/nestjs-metadata-grpc-js/hero.ts
@@ -1,6 +1,8 @@
 /* eslint-disable */
-import { Metadata } from "@grpc/grpc-js";
+import { handleBidiStreamingCall, Metadata } from "@grpc/grpc-js";
+import type { handleUnaryCall, UntypedServiceImplementation } from "@grpc/grpc-js";
 import { GrpcMethod, GrpcStreamMethod } from "@nestjs/microservices";
+import * as _m0 from "protobufjs/minimal";
 import { Observable } from "rxjs";
 
 export const protobufPackage = "hero";
@@ -25,6 +27,170 @@ export interface Villain {
 
 export const HERO_PACKAGE_NAME = "hero";
 
+function createBaseHeroById(): HeroById {
+  return { id: 0 };
+}
+
+export const HeroById = {
+  encode(message: HeroById, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer {
+    if (message.id !== 0) {
+      writer.uint32(8).int32(message.id);
+    }
+    return writer;
+  },
+
+  decode(input: _m0.Reader | Uint8Array, length?: number): HeroById {
+    const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input);
+    let end = length === undefined ? reader.len : reader.pos + length;
+    const message = createBaseHeroById();
+    while (reader.pos < end) {
+      const tag = reader.uint32();
+      switch (tag >>> 3) {
+        case 1:
+          if (tag !== 8) {
+            break;
+          }
+
+          message.id = reader.int32();
+          continue;
+      }
+      if ((tag & 7) === 4 || tag === 0) {
+        break;
+      }
+      reader.skipType(tag & 7);
+    }
+    return message;
+  },
+};
+
+function createBaseVillainById(): VillainById {
+  return { id: 0 };
+}
+
+export const VillainById = {
+  encode(message: VillainById, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer {
+    if (message.id !== 0) {
+      writer.uint32(8).int32(message.id);
+    }
+    return writer;
+  },
+
+  decode(input: _m0.Reader | Uint8Array, length?: number): VillainById {
+    const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input);
+    let end = length === undefined ? reader.len : reader.pos + length;
+    const message = createBaseVillainById();
+    while (reader.pos < end) {
+      const tag = reader.uint32();
+      switch (tag >>> 3) {
+        case 1:
+          if (tag !== 8) {
+            break;
+          }
+
+          message.id = reader.int32();
+          continue;
+      }
+      if ((tag & 7) === 4 || tag === 0) {
+        break;
+      }
+      reader.skipType(tag & 7);
+    }
+    return message;
+  },
+};
+
+function createBaseHero(): Hero {
+  return { id: 0, name: "" };
+}
+
+export const Hero = {
+  encode(message: Hero, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer {
+    if (message.id !== 0) {
+      writer.uint32(8).int32(message.id);
+    }
+    if (message.name !== "") {
+      writer.uint32(18).string(message.name);
+    }
+    return writer;
+  },
+
+  decode(input: _m0.Reader | Uint8Array, length?: number): Hero {
+    const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input);
+    let end = length === undefined ? reader.len : reader.pos + length;
+    const message = createBaseHero();
+    while (reader.pos < end) {
+      const tag = reader.uint32();
+      switch (tag >>> 3) {
+        case 1:
+          if (tag !== 8) {
+            break;
+          }
+
+          message.id = reader.int32();
+          continue;
+        case 2:
+          if (tag !== 18) {
+            break;
+          }
+
+          message.name = reader.string();
+          continue;
+      }
+      if ((tag & 7) === 4 || tag === 0) {
+        break;
+      }
+      reader.skipType(tag & 7);
+    }
+    return message;
+  },
+};
+
+function createBaseVillain(): Villain {
+  return { id: 0, name: "" };
+}
+
+export const Villain = {
+  encode(message: Villain, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer {
+    if (message.id !== 0) {
+      writer.uint32(8).int32(message.id);
+    }
+    if (message.name !== "") {
+      writer.uint32(18).string(message.name);
+    }
+    return writer;
+  },
+
+  decode(input: _m0.Reader | Uint8Array, length?: number): Villain {
+    const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input);
+    let end = length === undefined ? reader.len : reader.pos + length;
+    const message = createBaseVillain();
+    while (reader.pos < end) {
+      const tag = reader.uint32();
+      switch (tag >>> 3) {
+        case 1:
+          if (tag !== 8) {
+            break;
+          }
+
+          message.id = reader.int32();
+          continue;
+        case 2:
+          if (tag !== 18) {
+            break;
+          }
+
+          message.name = reader.string();
+          continue;
+      }
+      if ((tag & 7) === 4 || tag === 0) {
+        break;
+      }
+      reader.skipType(tag & 7);
+    }
+    return message;
+  },
+};
+
 export interface HeroServiceClient {
   findOneHero(request: HeroById, metadata?: Metadata): Observable<Hero>;
 
@@ -57,3 +223,40 @@ export function HeroServiceControllerMethods() {
 }
 
 export const HERO_SERVICE_NAME = "HeroService";
+
+export type HeroServiceService = typeof HeroServiceService;
+export const HeroServiceService = {
+  findOneHero: {
+    path: "/hero.HeroService/FindOneHero",
+    requestStream: false,
+    responseStream: false,
+    requestSerialize: (value: HeroById) => Buffer.from(HeroById.encode(value).finish()),
+    requestDeserialize: (value: Buffer) => HeroById.decode(value),
+    responseSerialize: (value: Hero) => Buffer.from(Hero.encode(value).finish()),
+    responseDeserialize: (value: Buffer) => Hero.decode(value),
+  },
+  findOneVillain: {
+    path: "/hero.HeroService/FindOneVillain",
+    requestStream: false,
+    responseStream: false,
+    requestSerialize: (value: VillainById) => Buffer.from(VillainById.encode(value).finish()),
+    requestDeserialize: (value: Buffer) => VillainById.decode(value),
+    responseSerialize: (value: Villain) => Buffer.from(Villain.encode(value).finish()),
+    responseDeserialize: (value: Buffer) => Villain.decode(value),
+  },
+  findManyVillain: {
+    path: "/hero.HeroService/FindManyVillain",
+    requestStream: true,
+    responseStream: true,
+    requestSerialize: (value: VillainById) => Buffer.from(VillainById.encode(value).finish()),
+    requestDeserialize: (value: Buffer) => VillainById.decode(value),
+    responseSerialize: (value: Villain) => Buffer.from(Villain.encode(value).finish()),
+    responseDeserialize: (value: Buffer) => Villain.decode(value),
+  },
+} as const;
+
+export interface HeroServiceServer extends UntypedServiceImplementation {
+  findOneHero: handleUnaryCall<HeroById, Hero>;
+  findOneVillain: handleUnaryCall<VillainById, Villain>;
+  findManyVillain: handleBidiStreamingCall<VillainById, Villain>;
+}
diff --git a/src/main.ts b/src/main.ts
index f8c73b30..9dd0ad90 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -310,52 +310,50 @@ export function generateFile(ctx: Context, fileDesc: FileDescriptorProto): [stri
 
   visitServices(fileDesc, sourceInfo, (serviceDesc, sInfo) => {
     if (options.nestJs) {
-      // NestJS is sufficiently different that we special case all of the client/server interfaces
-
+      // NestJS is sufficiently different that we special case the client/server interfaces
       // generate nestjs grpc client interface
       chunks.push(generateNestjsServiceClient(ctx, fileDesc, sInfo, serviceDesc));
       // and the service controller interface
       chunks.push(generateNestjsServiceController(ctx, fileDesc, sInfo, serviceDesc));
       // generate nestjs grpc service controller decorator
       chunks.push(generateNestjsGrpcServiceMethodsDecorator(ctx, serviceDesc));
-
       let serviceConstName = `${camelToSnake(serviceDesc.name)}_NAME`;
       if (!serviceDesc.name.toLowerCase().endsWith("service")) {
         serviceConstName = `${camelToSnake(serviceDesc.name)}_SERVICE_NAME`;
       }
-
       chunks.push(code`export const ${serviceConstName} = "${serviceDesc.name}";`);
-    } else {
-      const uniqueServices = [...new Set(options.outputServices)].sort();
-      uniqueServices.forEach((outputService) => {
-        if (outputService === ServiceOption.GRPC) {
-          chunks.push(generateGrpcJsService(ctx, fileDesc, sInfo, serviceDesc));
-        } else if (outputService === ServiceOption.NICE_GRPC) {
-          chunks.push(generateNiceGrpcService(ctx, fileDesc, sInfo, serviceDesc));
-        } else if (outputService === ServiceOption.GENERIC) {
-          chunks.push(generateGenericServiceDefinition(ctx, fileDesc, sInfo, serviceDesc));
-        } else if (outputService === ServiceOption.DEFAULT) {
-          // This service could be Twirp or grpc-web or JSON (maybe). So far all of their
-          // interfaces are fairly similar so we share the same service interface.
-          chunks.push(generateService(ctx, fileDesc, sInfo, serviceDesc));
-
-          if (options.outputClientImpl === true) {
-            chunks.push(generateServiceClientImpl(ctx, fileDesc, serviceDesc));
-          } else if (options.outputClientImpl === "grpc-web") {
-            chunks.push(generateGrpcClientImpl(ctx, fileDesc, serviceDesc));
-            chunks.push(generateGrpcServiceDesc(fileDesc, serviceDesc));
-            serviceDesc.method.forEach((method) => {
-              if (!method.clientStreaming) {
-                chunks.push(generateGrpcMethodDesc(ctx, serviceDesc, method));
-              }
-              if (method.serverStreaming) {
-                hasServerStreamingMethods = true;
-              }
-            });
-          }
-        }
-      });
     }
+
+    const uniqueServices = [...new Set(options.outputServices)].sort();
+    uniqueServices.forEach((outputService) => {
+      if (outputService === ServiceOption.GRPC) {
+        chunks.push(generateGrpcJsService(ctx, fileDesc, sInfo, serviceDesc));
+      } else if (outputService === ServiceOption.NICE_GRPC) {
+        chunks.push(generateNiceGrpcService(ctx, fileDesc, sInfo, serviceDesc));
+      } else if (outputService === ServiceOption.GENERIC) {
+        chunks.push(generateGenericServiceDefinition(ctx, fileDesc, sInfo, serviceDesc));
+      } else if (outputService === ServiceOption.DEFAULT) {
+        // This service could be Twirp or grpc-web or JSON (maybe). So far all of their
+        // interfaces are fairly similar so we share the same service interface.
+        chunks.push(generateService(ctx, fileDesc, sInfo, serviceDesc));
+
+        if (options.outputClientImpl === true) {
+          chunks.push(generateServiceClientImpl(ctx, fileDesc, serviceDesc));
+        } else if (options.outputClientImpl === "grpc-web") {
+          chunks.push(generateGrpcClientImpl(ctx, fileDesc, serviceDesc));
+          chunks.push(generateGrpcServiceDesc(fileDesc, serviceDesc));
+          serviceDesc.method.forEach((method) => {
+            if (!method.clientStreaming) {
+              chunks.push(generateGrpcMethodDesc(ctx, serviceDesc, method));
+            }
+            if (method.serverStreaming) {
+              hasServerStreamingMethods = true;
+            }
+          });
+        }
+      }
+    });
+
     serviceDesc.method.forEach((methodDesc, _index) => {
       if (methodDesc.serverStreaming || methodDesc.clientStreaming) {
         hasStreamingMethods = true;
diff --git a/src/options.ts b/src/options.ts
index 5f37bc7a..89cfe541 100644
--- a/src/options.ts
+++ b/src/options.ts
@@ -1,5 +1,3 @@
-import { parse } from "path";
-import { Code } from "ts-poet";
 import { ToStringOpts } from "ts-poet/build/Code";
 
 export enum LongOption {
@@ -203,15 +201,19 @@ export function optionsFromParameter(parameter: string | undefined): Options {
   if ((options.outputServices as any) === false) {
     options.outputServices = [ServiceOption.NONE];
   }
-
   // Existing type-coercion inside parseParameter leaves a little to be desired.
   if (typeof options.outputServices == "string") {
     options.outputServices = [options.outputServices];
   }
-
-  if (options.outputServices.length == 0) {
+  // Assume the user wants the default service output, unless they're using nestJs, which has
+  // its own controllers output (although nestjs users can ask for other services too).
+  if (options.outputServices.length == 0 && !options.nestJs) {
     options.outputServices = [ServiceOption.DEFAULT];
   }
+  // If using nestJs + other services, add the encode methods back
+  if (options.nestJs && options.outputServices.length > 0) {
+    options.outputEncodeMethods = true;
+  }
 
   if ((options.useDate as any) === true) {
     // Treat useDate=true as DATE
diff --git a/src/utils.ts b/src/utils.ts
index eeb19a80..885e897f 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -208,11 +208,9 @@ export class FormattedMethodDescriptor implements MethodDescriptorProto {
    */
   public static formatName(methodName: string, options: Options) {
     let result = methodName;
-
     if (options.lowerCaseServiceMethods || options.outputServices.includes(ServiceOption.GRPC)) {
       if (options.snakeToCamel) result = camelCaseGrpc(result);
     }
-
     return result;
   }
 }


Test patch:
diff --git a/tests/options-test.ts b/tests/options-test.ts
index fdaf2b27..6cb18944 100644
--- a/tests/options-test.ts
+++ b/tests/options-test.ts
@@ -2,7 +2,6 @@ import { DateOption, optionsFromParameter, ServiceOption } from "../src/options"
 
 describe("options", () => {
   it("can set outputJsonMethods with nestJs=true", () => {
-    console.log(optionsFromParameter("nestJs=true,outputJsonMethods=true"));
     expect(optionsFromParameter("nestJs=true,outputJsonMethods=true")).toMatchInlineSnapshot(`
       {
         "M": {},
@@ -33,9 +32,7 @@ describe("options", () => {
         "outputJsonMethods": true,
         "outputPartialMethods": false,
         "outputSchema": false,
-        "outputServices": [
-          "default",
-        ],
+        "outputServices": [],
         "outputTypeAnnotations": false,
         "outputTypeRegistry": false,
         "removeEnumPrefix": false,
@@ -70,11 +67,12 @@ describe("options", () => {
     `);
   });
 
-  it("can set outputJsonMethods with nestJs=true", () => {
+  it("can set addGrpcMetadata=false", () => {
     const options = optionsFromParameter("outputClientImpl=grpc-web,addGrpcMetadata=false");
     expect(options).toMatchObject({
       outputClientImpl: "grpc-web",
       addGrpcMetadata: false,
+      outputServices: ["default"],
     });
   });