Repository: stephenh/ts-proto
Pull request #357 (2021-09-06 13:26:43)

feat: Service generation option
Fixes https://github.com/stephenh/ts-proto/issues/349

Added some minor refactoring to extract out the service option to its own Enum.


Patch:
diff --git a/README.markdown b/README.markdown
index a24fa3d2..1a9f0b58 100644
--- a/README.markdown
+++ b/README.markdown
@@ -230,7 +230,7 @@ Generated code will be placed in the Gradle build directory.
   The default behavior is `forceLong=number`, which will internally still use the `long` library to encode/decode values on the wire (so you will still see a `util.Long = Long` line in your output), but will convert the `long` values to `number` automatically for you. Note that a runtime error is thrown if, while doing this conversion, a 64-bit value is larger than can be correctly stored as a `number`.
 
 - With `--ts_proto_opt=esModuleInterop=true` changes output to be `esModuleInterop` compliant.
-  
+
   Specifically the `Long` imports will be generated as `import Long from 'long'` instead of `import * as Long from 'long'`.
 
 - With `--ts_proto_opt=env=node` or `browser` or `both`, ts-proto will make environment-specific assumptions in your output. This defaults to `both`, which makes no environment-specific assumptions.
@@ -259,20 +259,20 @@ Generated code will be placed in the Gradle build directory.
   ```
 
   However, the type-safety of `useOptionals=false` is admittedly tedious if you have many inherently-unused fields, so you can use `useOptionals=true` if that trade-off makes sense for your project.
-  
+
   You can also use the generated `SomeMessage.fromPartial` methods to opt into the optionality on a per-call-site basis. The `fromPartial` allows the creator/writer to have default values applied (i.e. `undefined` --> `0`), and the return value will still be the non-optional type that provides a consistent view (i.e. always `0`) to clients.
 
-  Eventually if TypesCript supports [Exact Types](https://github.com/microsoft/TypeScript/issues/12936), that should allow ts-proto to switch to `useOptionals=true` as the default/only behavior, have the generated `Message.encode`/`Message.toPartial`/etc. methods accept `Exact<T>` versions of the message types, and the result would be both safe + succinct.
+  Eventually if TypeScript supports [Exact Types](https://github.com/microsoft/TypeScript/issues/12936), that should allow ts-proto to switch to `useOptionals=true` as the default/only behavior, have the generated `Message.encode`/`Message.toPartial`/etc. methods accept `Exact<T>` versions of the message types, and the result would be both safe + succinct.
 
   Also see the comment in [this issue](https://github.com/stephenh/ts-proto/issues/120#issuecomment-678375833) which explains the nuance behind making all fields optional (currently `useOptionals` only makes message fields optional), specifically that a message created with `const message: Message = { ...key not set... }` (so `key` is `undefined`) vs. `const message = Message.decode(...key not set...)` (so `key` is the default value) would look different to clients.
 
   Note that RPC methods, like `service.ping({ key: ... })`, accept `DeepPartial` versions of the request messages, because of the same rationale that it makes it easy for the writer call-site to get default values for free, and because the "reader" is the internal ts-proto serialization code, it can apply the defaults as necessary.
 
 - With `--ts_proto_opt=exportCommonSymbols=false`, utility types like `DeepPartial` won't be `export`d.
-  
+
   This should make it possible to use create barrel imports of the generated output, i.e. `import * from ./foo` and `import * from ./bar`.
-  
-  Note that if you have the same message name used in multiple `*.proto` files, you will still get import conflicts. 
+
+  Note that if you have the same message name used in multiple `*.proto` files, you will still get import conflicts.
 
 - With `--ts_proto_opt=oneof=unions`, `oneof` fields will be generated as ADTs.
 
@@ -336,6 +336,8 @@ Generated code will be placed in the Gradle build directory.
 
 - With `--ts_proto_opt=outputServices=generic-definitions`, ts-proto will output generic (framework-agnostic) service definitions.
 
+- With `--ts_proto_opt=outputServices=false`, or `=none`, ts-proto will output NO service definitions.
+
 - With `--ts_proto_opt=emitImportedFiles=false`, ts-proto will not emit `google/protobuf/*` files unless you explicit add files to `protoc` like this
 `protoc --plugin=./node_modules/.bin/protoc-gen-ts_proto my_message.proto google/protobuf/duration.proto`
 
@@ -410,7 +412,7 @@ If you want fields where you can model set/unset, see Wrapper Types.
 # Wrapper Types
 
 In core Protobuf, unset primitive fields become their respective default values (so you loose ability to distinguish "unset" from "default").
- 
+
 However, unset message fields stay `null`.
 
 This allows a cute hack where you can model a logical `string | unset` by creating a field that is technically a message (i.e. so it can stay `null` for the unset case), but the message only has a single string field (i.e for storing the value in the set case).
@@ -426,9 +428,9 @@ This makes dealing with `string | unset` in your code much nicer, albeit it's un
 Numbers are by default assumed to be plain JavaScript `number`s.
 
 This is fine for Protobuf types like `int32` and `float`, but 64-bit types like `int64` can't be 100% represented by JavaScript's `number` type, because `int64` can have larger/smaller values than `number`.
- 
+
 ts-proto's default configuration (which is `forceLong=number`) is to still use `number` for 64-bit fields, and then throw an error if a value (at runtime) is larger than `Number.MAX_SAFE_INTEGER`.
- 
+
 If you expect to use 64-bit / higher-than-`MAX_SAFE_INTEGER` values, then you can use the ts-proto `forceLong` option, which uses the [long](https://www.npmjs.com/package/long) npm package to support the entire range of 64-bit values.
 
 The protobuf number types map to JavaScript types based on the `forceLong` config option:
diff --git a/src/generate-nestjs.ts b/src/generate-nestjs.ts
index 0856f2fc..2194b569 100644
--- a/src/generate-nestjs.ts
+++ b/src/generate-nestjs.ts
@@ -13,6 +13,7 @@ import { contextTypeVar } from './main';
 import { assertInstanceOf, FormattedMethodDescriptor, maybeAddComment, singular } from './utils';
 import { camelCase } from './case';
 import { Context } from './context';
+import { ServiceOption } from './options';
 
 export function generateNestjsServiceController(
   ctx: Context,
@@ -23,7 +24,7 @@ export function generateNestjsServiceController(
   const { options } = ctx;
   const chunks: Code[] = [];
 
-  const Metadata = options.outputServices === 'grpc-js' ? imp('Metadata@@grpc/grpc-js') : imp('Metadata@grpc');
+  const Metadata = options.outputServices === ServiceOption.GRPC ? imp('Metadata@@grpc/grpc-js') : imp('Metadata@grpc');
 
   maybeAddComment(sourceInfo, chunks, serviceDesc.options?.deprecated);
   const t = options.context ? `<${contextTypeVar}>` : '';
@@ -96,7 +97,7 @@ export function generateNestjsServiceClient(
   const { options } = ctx;
   const chunks: Code[] = [];
 
-  const Metadata = options.outputServices === 'grpc-js' ? imp('Metadata@@grpc/grpc-js') : imp('Metadata@grpc');
+  const Metadata = options.outputServices === ServiceOption.GRPC ? imp('Metadata@@grpc/grpc-js') : imp('Metadata@grpc');
 
   maybeAddComment(sourceInfo, chunks);
   const t = options.context ? `<${contextTypeVar}>` : ``;
diff --git a/src/main.ts b/src/main.ts
index aaf984d9..f99a4625 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -56,7 +56,7 @@ import {
 } from './generate-grpc-web';
 import { generateEnum } from './enums';
 import { visit, visitServices } from './visit';
-import { EnvOption, LongOption, OneofOption, Options, DateOption } from './options';
+import { EnvOption, LongOption, OneofOption, Options, ServiceOption, DateOption } from './options';
 import { Context } from './context';
 import { generateSchema } from './schema';
 import { ConditionalOutput } from 'ts-poet/build/ConditionalOutput';
@@ -183,11 +183,11 @@ export function generateFile(ctx: Context, fileDesc: FileDescriptorProto): [stri
       }
 
       chunks.push(code`export const ${serviceConstName} = "${serviceDesc.name}";`);
-    } else if (options.outputServices === 'grpc-js') {
+    } else if (options.outputServices === ServiceOption.GRPC) {
       chunks.push(generateGrpcJsService(ctx, fileDesc, sInfo, serviceDesc));
-    } else if (options.outputServices === 'generic-definitions') {
+    } else if (options.outputServices === ServiceOption.GENERIC) {
       chunks.push(generateGenericServiceDefinition(ctx, fileDesc, sInfo, serviceDesc));
-    } else {
+    } else if (options.outputServices === 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));
@@ -207,7 +207,7 @@ export function generateFile(ctx: Context, fileDesc: FileDescriptorProto): [stri
     }
   });
 
-  if (!options.outputServices && options.outputClientImpl && fileDesc.service.length > 0) {
+  if (options.outputServices === ServiceOption.DEFAULT && options.outputClientImpl && fileDesc.service.length > 0) {
     if (options.outputClientImpl === true) {
       chunks.push(generateRpcType(ctx));
     } else if (options.outputClientImpl === 'grpc-web') {
diff --git a/src/options.ts b/src/options.ts
index b33c6835..7201311a 100644
--- a/src/options.ts
+++ b/src/options.ts
@@ -21,6 +21,13 @@ export enum OneofOption {
   UNIONS = 'unions',
 }
 
+export enum ServiceOption {
+  GRPC = 'grpc-js',
+  GENERIC = 'generic-definitions',
+  DEFAULT = 'default',
+  NONE = 'none',
+}
+
 export type Options = {
   context: boolean;
   snakeToCamel: boolean;
@@ -36,7 +43,7 @@ export type Options = {
   stringEnums: boolean;
   constEnums: boolean;
   outputClientImpl: boolean | 'grpc-web';
-  outputServices: false | 'grpc-js' | 'generic-definitions';
+  outputServices: ServiceOption;
   addGrpcMetadata: boolean;
   addNestjsRestParameter: boolean;
   returnObservable: boolean;
@@ -68,7 +75,7 @@ export function defaultOptions(): Options {
     stringEnums: false,
     constEnums: false,
     outputClientImpl: true,
-    outputServices: false,
+    outputServices: ServiceOption.DEFAULT,
     returnObservable: false,
     addGrpcMetadata: false,
     addNestjsRestParameter: false,
@@ -109,6 +116,11 @@ export function optionsFromParameter(parameter: string): Options {
     options.forceLong = LongOption.LONG;
   }
 
+  // Treat outputServices=false as NONE
+  if ((options.outputServices as any) === false) {
+    options.outputServices = ServiceOption.NONE;
+  }
+
   if ((options.useDate as any) === true) {
     // Treat useDate=true as DATE
     options.useDate = DateOption.DATE;
diff --git a/src/utils.ts b/src/utils.ts
index 87b87507..5b3f8c66 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -2,7 +2,7 @@ import { code, Code } from 'ts-poet';
 import { CodeGeneratorRequest, FileDescriptorProto, MethodDescriptorProto, MethodOptions } from 'ts-proto-descriptors';
 import ReadStream = NodeJS.ReadStream;
 import { SourceDescription } from './sourceInfo';
-import { Options } from './options';
+import { Options, ServiceOption } from './options';
 import { camelCase } from './case';
 
 export function protoFilesToGenerate(request: CodeGeneratorRequest): FileDescriptorProto[] {
@@ -160,7 +160,7 @@ export class FormattedMethodDescriptor implements MethodDescriptorProto {
   public static formatName(methodName: string, options: Options) {
     let result = methodName;
 
-    if (options.lowerCaseServiceMethods || options.outputServices === 'grpc-js') {
+    if (options.lowerCaseServiceMethods || options.outputServices === ServiceOption.GRPC) {
       result = camelCase(result);
     }
 


Test patch:
diff --git a/tests/options-test.ts b/tests/options-test.ts
index 9aaf9339..80f06659 100644
--- a/tests/options-test.ts
+++ b/tests/options-test.ts
@@ -1,4 +1,4 @@
-import { optionsFromParameter } from '../src/options';
+import { optionsFromParameter, ServiceOption } from '../src/options';
 
 describe('options', () => {
   it('can set outputJsonMethods with nestJs=true', () => {
@@ -22,7 +22,7 @@ describe('options', () => {
         "outputJsonMethods": true,
         "outputPartialMethods": false,
         "outputSchema": false,
-        "outputServices": false,
+        "outputServices": "default",
         "outputTypeRegistry": false,
         "returnObservable": false,
         "snakeToCamel": true,
@@ -41,4 +41,18 @@ describe('options', () => {
       addGrpcMetadata: false,
     });
   });
+
+  it('can set outputServices to false', () => {
+    const options = optionsFromParameter('outputServices=false');
+    expect(options).toMatchObject({
+      outputServices: ServiceOption.NONE,
+    });
+  });
+
+  it('can set outputServices to grpc', () => {
+    const options = optionsFromParameter('outputServices=grpc-js');
+    expect(options).toMatchObject({
+      outputServices: ServiceOption.GRPC,
+    });
+  });
 });