Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

L70: @grpc/proto-loader TypeScript Type Generator CLI Tool #183

Merged
merged 11 commits into from
Jul 21, 2020
218 changes: 218 additions & 0 deletions L70-node-proto-loader-type-generator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
@grpc/proto-loader TypeScript Type Generator CLI Tool
----
* Author(s): murgatroid99
* Approver: wenbozhu
* Status: Final
* Implemented in: Node.js
* Last updated: 2020-06-21
* Discussion at: https://groups.google.com/g/grpc-io/c/FwLprMC-5Qc

## Abstract

Add a tool to the `@grpc/proto-loader` to generate types that describe the objects that will be generated by loading the output of `@grpc/proto-loader` into `grpc` or `@grpc/grpc-js`.

## Background

`@grpc/proto-loader` outputs objects with types determined by the `.proto` files loaded at runtime, so it is currently very difficult to get compile-time type information about those objects. As TypeScript becomes increasingly popular, that compile-time type information becomes increasingly desirable.


### Related Proposals:
* L23 Standalone Node.js gRPC+Protobuf.js Library API
* L43 Node Message Type Information

## Proposal

In the `@grpc/proto-loader` library, provide a command line tool that will generate the types of the objects that will be created by passing the output of `load` or `loadSync` to `grpc.loadPackageDefinition` when loading a specific set of `.proto` files with a specific set of options. This will allow users to write code like this to use the type information in the rest of the code:

```ts
import * as grpc from 'grpc';
// OR
import * as grpc from '@grpc/grpc-js';

import { ProtoGrpcType } from 'generated/proto-file-name_proto'; // File generated by the tool is generated/proto-file-name_proto.ts
import * as protoLoader from '@grpc/proto-loader';

import { MessageName } from 'generated/fully/qualified/package/MessageName';
import { ServiceNameHandler } from 'generated/fully/qualified/package/ServiceName';

const packageDefinition = protoLoader.loadSync('other/path/to/proto-file-name.proto', options);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's quite common for folks to want to load more than one proto file. It would be good to switch this over to an array instead of a simple string to show how multiple types may be loaded.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This API does accept an array of strings as an alternative to a single string. When loading multiple files you can get the type of the output from grpc.loadPackageDefinition by loading all of the corresponding ProtoGrpcType interfaces from the corresponding generating files and combining them with &.

Also, maybe this isn't clear, but the proposed CLI tool can also accept multiple filenames to generate types for.

const loadedPackageDefinition = grpc.loadPackageDefinition(packageDefinition) as unknown as ProtoGrpcType; // How the types will be used

const requestObject: MessageName = {
field1: value1,
field2: value2
};

const serviceHandler: ServiceNameHandler = {
// Call implicitly has the correct arity (unary, client streaming, etc.) and the call and callback have the correct request and response types
methodName(call, callback) {
// Implementation
}
}

const server = new grpc.Server();
server.addService(loadedPackageDefinition.fully.qualified.package.ServiceName.service, serviceHandler);

```

The tool will be named `proto-loader-gen-types` and will have this usage:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this work as a protoc plugin? Or will it stand on it's own.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a standalone tool, distributed in the @grpc/proto-loader package. It uses Protobuf.js rather than protoc for parsing.


```
proto-loader-gen-types [OPTIONS] <proto file name> ...
```

The options will correspond directly to the `protoLoader.load` options as follows:

- `--keepCase`: Preserve the case of field names
- `--longs=String|Number`: Specify the type that should be used to output 64 bit integer values
- `--enums=String`: Specify that enum values should be output as strings
- `--bytes=Array|String`: Specify the type that should be used to output `bytes` fields
- `--defaults`: Indicates that default values should be output for missing fields
- `--arrays`: Indicates that empty arrays should be output for missing repeated fields even if `--defaults` is unset
- `--objects`: Indicates that empty objects should be output for missing message fields even if `--defaults` is unset
- `--oneofs`: Indicates that virtual "oneof" fields will be set to the present field's name in the output
- `--includeDirs=<directory>`, `-I <directory>`: A directory to search for included `.proto` files. Can be passed multiple times to include multiple directories

- `--outDir=<directory>`, `-O <directory>`: The directory in which to output files
- `--grpcLib=grpc|@grpc/grpc-js`: The gRPC implementation library that these types will be used with
- `--verbose`, `-v`: Enable various logging output
- `--includeComments`: Include comments from the `.proto` files in the generated files

The output will be one file for each `message` and `enum` loaded, with file paths based on the package and type names, plus a master file per input file that combines all of those to produce the type that the user will load, as described above. `ProtoGrpcType` types from different files can be intersected to get the type that results from loading those files together at runtime. Messages will have an additional type generated, suffixed with `__Output`, that describes the type of objects that will be output by gRPC, i.e. response messages on the client, and request messages on the server. These "output" message types will be subtypes of the main message type, restricted based on the options that are set.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you expand a bit on the need for stricter types, here or under the rationales section?

Also, I wonder if "_Strict" will work better. Conceivably, I may want to use the strict types for both input and output messages, e.g. to improve the overall type conformity of my codebase.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have reworded the part of the rationales section about the stricter types.


To support this generated code, `@grpc/proto-loader` will need to re-export the `Long` type from `protobufjs`, because that is a type that can be used by generated message types.

### Example Generated Code

With options that will probably be common: `--keep-case`, `--longs=String`, `--enums=String`, `--defaults`, `--oneofs`, and `--grpcLib=@grpc/grpc-js`

#### Input

```protobuf
// filename.proto
syntax = "proto3";

package package_name.subpackage_name;

enum EnumName {
OPTION0 = 0;
OPTION1 = 1;
}

message MessageName {
string string_value = 1;
int32 number_value = 2;
EnumName enum_value = 3;
int64 long_value = 4;
oneof oneof_value {
bool bool_value = 5;
bytes bytes_value = 6;
}
}

service ServiceName {
rpc Method (MessageName) returns (MessageName);
}
```

#### Output

```ts
// package_name/subpackage_name/EnumName.ts

export enum EnumName {
OPTION0 = 0,
OPTION1 = 1,
}

// package_name/subpackage_name/MessageName.ts

import { EnumName as _package_name_subpackage_name_EnumName } from '../../package_name/subpackage_name/EnumName';
import { Long } from '@grpc/proto-loader';

export interface MessageName {
'string_value'?: (string);
'number_value'?: (number);
'enum_value'?: (_package_name_subpackage_name_EnumName | keyof typeof _package_name_subpackage_name_EnumName);
'long_value'?: (number | string | Long);
'bool_value'?: (boolean);
'bytes_value'?: (Buffer | Uint8Array | string);
'oneof_value'?: "bool_value"|"bytes_value";
}

export interface MessageName__Output {
'string_value': (string);
'number_value': (number);
'enum_value': (keyof typeof _package_name_subpackage_name_EnumName);
'long_value': (string);
'bool_value'?: (boolean);
'bytes_value'?: (Buffer);
'oneof_value': "bool_value"|"bytes_value";
}

// package_name/subpackage_name/ServiceName.ts

import * as grpc from '@grpc/grpc-js'
import { MessageName as _package_name_subpackage_name_MessageName, MessageName__Output as _package_name_subpackage_name_MessageName__Output } from '../../package_name/subpackage_name/MessageName';

export interface ServiceNameClient extends grpc.Client {
Method(argument: _package_name_subpackage_name_MessageName, metadata: grpc.Metadata, options: grpc.CallOptions, callback: (error?: grpc.ServiceError, result?: _package_name_subpackage_name_MessageName__Output) => void): grpc.ClientUnaryCall;
Method(argument: _package_name_subpackage_name_MessageName, metadata: grpc.Metadata, callback: (error?: grpc.ServiceError, result?: _package_name_subpackage_name_MessageName__Output) => void): grpc.ClientUnaryCall;
Method(argument: _package_name_subpackage_name_MessageName, metadata: grpc.Metadata, options: grpc.CallOptions, callback: (error?: grpc.ServiceError, result?: _package_name_subpackage_name_MessageName__Output) => void): grpc.ClientUnaryCall;
Method(argument: _package_name_subpackage_name_MessageName, metadata: grpc.Metadata, callback: (error?: grpc.ServiceError, result?: _package_name_subpackage_name_MessageName__Output) => void): grpc.ClientUnaryCall;
method(argument: _package_name_subpackage_name_MessageName, metadata: grpc.Metadata, options: grpc.CallOptions, callback: (error?: grpc.ServiceError, result?: _package_name_subpackage_name_MessageName__Output) => void): grpc.ClientUnaryCall;
method(argument: _package_name_subpackage_name_MessageName, metadata: grpc.Metadata, callback: (error?: grpc.ServiceError, result?: _package_name_subpackage_name_MessageName__Output) => void): grpc.ClientUnaryCall;
method(argument: _package_name_subpackage_name_MessageName, metadata: grpc.Metadata, options: grpc.CallOptions, callback: (error?: grpc.ServiceError, result?: _package_name_subpackage_name_MessageName__Output) => void): grpc.ClientUnaryCall;
method(argument: _package_name_subpackage_name_MessageName, metadata: grpc.Metadata, callback: (error?: grpc.ServiceError, result?: _package_name_subpackage_name_MessageName__Output) => void): grpc.ClientUnaryCall;

}

export interface ServiceNameHandlers {
Method(call: grpc.ServerUnaryCall<_package_name_subpackage_name_MessageName, _package_name_subpackage_name_MessageName__Output>, callback: grpc.sendUnaryData<_package_name_subpackage_name_MessageName__Output>): void;

}

// filename.ts

import * as grpc from '@grpc/grpc-js';
import { ServiceDefinition, EnumTypeDefinition, MessageTypeDefinition } from '@grpc/proto-loader';

import { ServiceNameClient as _package_name_subpackage_name_ServiceNameClient } from './package_name/subpackage_name/ServiceName';

type ConstructorArguments<Constructor> = Constructor extends new (...args: infer Args) => any ? Args: never;
type SubtypeConstructor<Constructor, Subtype> = {
new(...args: ConstructorArguments<Constructor>): Subtype;
}

export interface ProtoGrpcType {
package_name: {
subpackage_name: {
EnumName: EnumTypeDefinition
MessageName: MessageTypeDefinition
ServiceName: SubtypeConstructor<typeof grpc.Client, _package_name_subpackage_name_ServiceNameClient> & { service: ServiceDefinition }
}
}
}
```

## Rationale

The goal is to have type information available when editing and building code that uses `@grpc/proto-loader` to load `.proto` files at runtime. It is not possible to infer this type information from the interfaces currently provided by this library because TypeScript cannot infer type information from `.proto` files. The only other option is to generate this type information separately. The recommended usage of the `@grpc/proto-loader` library is to call `load` or `loadSync` and then pass the result of that to `grpc.loadPackageDefinition`, so the type of the final result of that will be useful to most users. The output of `load` and `loadSync` are objects with runtime data that describes the resulting types, so the types of those objects are significantly less useful, and the final type that we will generate here cannot be effectively inferred from those types.

The type parameters `grpc.Client`, `grpc.Metadata`, and `grpc.CallOptions` are all needed because each of those types is used in the resulting type, and none of them can be inferred from the others. The type `grpc.Metadata` also impacts the resulting type, but its type is the same across the two implementations so it can be defined independently of them.

These alternatives were considered to generating files in a directory structure based on the protobuf package structure:

- **Generate files in a directory structure mirroring the directory structure of the input files, all relative to the output directory.** This introduces the complexity of handling unusual import paths, including absolute paths and paths with `..`.
- **Generate files corresponding to the input files, all directly in the output directory.** This greatly increases the risk of filename conflicts.
- **Generate a single file with all of the types.** This creates a large, unwieldy file, making it hard for a human to evaluate changes. This can also greatly increase code duplication if multiple files need to be generated.

The gRPC implementation is specified at build time because the types depend on types from that library and the user should know at that point which implementation they are using. An alternative is to use generics to insert the types in a different way, but that increases the complexity of both the generated code and the code that uses it for relatively little gain.

The goal of generating two separate interfaces for each message type is to describe two separate things as narrowly as possible: the objects that users can pass to the library as input, and the objects that the library will output. We have more control over what the library outputs, so we can be more specific in that type. This simplifies handling of messages output by the library, while still allowing the same flexibility when providing input messages that users get with the JavaScript interface.

For example, all Protobuf 3 fields are optional, so the input type allows the user to omit fields, but with the `defaults` code generation option, we know that the library will always output the default value for omitted fields, so the output type can guarantee that every field will have a value.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LG. Thanks for the update.


## Implementation

I (murgatroid99) will implement this in the `@grpc/proto-loader` library in [PR #1474](https://github.com/grpc/grpc-node/pull/1474)