Skip to content

Commit

Permalink
feat: Allow simultaneous services and generic service definitions (#512)
Browse files Browse the repository at this point in the history
* allow services and generic service definitions to be generated at the same time

* refactor to array usage

* naming is clearer

* Add M1/ARM support for the test suite

* fix type

* wip

* Merge ARM/x86 Dockerfiles

* fix options in tests

* revert some accidental files

* fix options parsing

* might work

* fixup test

* clean up test

* drop unused option

Co-authored-by: Kyle Maxwell <kyle.maxwell@reddit.com>
  • Loading branch information
fizx and Kyle Maxwell committed Mar 4, 2022
1 parent f22a001 commit 680831e
Show file tree
Hide file tree
Showing 10 changed files with 231 additions and 28 deletions.
2 changes: 2 additions & 0 deletions README.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,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. These definitions contain descriptors for each method with links to request and response types, which allows to generate server and client stubs at runtime, and also generate strong types for them at compile time. An example of a library that uses this approach is [nice-grpc](https://github.com/deeplay-io/nice-grpc).

- With `--ts_proto_opt=outputServices=generic-definitions,outputServices=default`, ts-proto will output both generic definitions and interfaces. This is useful if you want to rely on the interfaces, but also have some reflection capabilities at runtime.

- 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
Expand Down
Binary file modified integration/fieldmask/fieldmask.bin
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
outputServices=generic-definitions,outputServices=generic-definitions=default
Binary file not shown.
25 changes: 25 additions & 0 deletions integration/generic-service-definitions-and-services/simple.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
syntax = "proto3";

package simple;

service Test {
option deprecated = true;

rpc Unary (TestMessage) returns (TestMessage) {}
rpc ServerStreaming (TestMessage) returns (stream TestMessage) {}
rpc ClientStreaming (stream TestMessage) returns (TestMessage) {}
rpc BidiStreaming (stream TestMessage) returns (stream TestMessage) {}
rpc Deprecated (TestMessage) returns (TestMessage) {
option deprecated = true;
}
rpc Idempotent (TestMessage) returns (TestMessage) {
option idempotency_level = IDEMPOTENT;
}
rpc NoSideEffects (TestMessage) returns (TestMessage) {
option idempotency_level = NO_SIDE_EFFECTS;
}
}

message TestMessage {
string value = 1;
}
155 changes: 155 additions & 0 deletions integration/generic-service-definitions-and-services/simple.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/* eslint-disable */
import { util, configure, Writer, Reader } from 'protobufjs/minimal';
import * as Long from 'long';

export const protobufPackage = 'simple';

export interface TestMessage {
value: string;
}

function createBaseTestMessage(): TestMessage {
return { value: '' };
}

export const TestMessage = {
encode(message: TestMessage, writer: Writer = Writer.create()): Writer {
if (message.value !== '') {
writer.uint32(10).string(message.value);
}
return writer;
},

decode(input: Reader | Uint8Array, length?: number): TestMessage {
const reader = input instanceof Reader ? input : new Reader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseTestMessage();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
message.value = reader.string();
break;
default:
reader.skipType(tag & 7);
break;
}
}
return message;
},

fromJSON(object: any): TestMessage {
return {
value: isSet(object.value) ? String(object.value) : '',
};
},

toJSON(message: TestMessage): unknown {
const obj: any = {};
message.value !== undefined && (obj.value = message.value);
return obj;
},

fromPartial<I extends Exact<DeepPartial<TestMessage>, I>>(object: I): TestMessage {
const message = createBaseTestMessage();
message.value = object.value ?? '';
return message;
},
};

/** @deprecated */
export const TestDefinition = {
name: 'Test',
fullName: 'simple.Test',
methods: {
unary: {
name: 'Unary',
requestType: TestMessage,
requestStream: false,
responseType: TestMessage,
responseStream: false,
options: {},
},
serverStreaming: {
name: 'ServerStreaming',
requestType: TestMessage,
requestStream: false,
responseType: TestMessage,
responseStream: true,
options: {},
},
clientStreaming: {
name: 'ClientStreaming',
requestType: TestMessage,
requestStream: true,
responseType: TestMessage,
responseStream: false,
options: {},
},
bidiStreaming: {
name: 'BidiStreaming',
requestType: TestMessage,
requestStream: true,
responseType: TestMessage,
responseStream: true,
options: {},
},
/** @deprecated */
deprecated: {
name: 'Deprecated',
requestType: TestMessage,
requestStream: false,
responseType: TestMessage,
responseStream: false,
options: {},
},
idempotent: {
name: 'Idempotent',
requestType: TestMessage,
requestStream: false,
responseType: TestMessage,
responseStream: false,
options: {
idempotencyLevel: 'IDEMPOTENT',
},
},
noSideEffects: {
name: 'NoSideEffects',
requestType: TestMessage,
requestStream: false,
responseType: TestMessage,
responseStream: false,
options: {
idempotencyLevel: 'NO_SIDE_EFFECTS',
},
},
},
} as const;

type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;

export type DeepPartial<T> = T extends Builtin
? T
: T extends Array<infer U>
? Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U>
? ReadonlyArray<DeepPartial<U>>
: T extends {}
? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;

type KeysOfUnion<T> = T extends T ? keyof T : never;
export type Exact<P, I extends P> = P extends Builtin
? P
: P & { [K in keyof P]: Exact<P[K], I[K]> } & Record<Exclude<keyof I, KeysOfUnion<P>>, never>;

// If you get a compile-error about 'Constructor<Long> and ... have no overlap',
// add '--ts_proto_opt=esModuleInterop=true' as a flag when calling 'protoc'.
if (util.Long !== Long) {
util.Long = Long as any;
configure();
}

function isSet(value: any): boolean {
return value !== null && value !== undefined;
}
51 changes: 30 additions & 21 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,27 +212,32 @@ export function generateFile(ctx: Context, fileDesc: FileDescriptorProto): [stri
}

chunks.push(code`export const ${serviceConstName} = "${serviceDesc.name}";`);
} else if (options.outputServices === ServiceOption.GRPC) {
chunks.push(generateGrpcJsService(ctx, fileDesc, sInfo, serviceDesc));
} else if (options.outputServices === ServiceOption.GENERIC) {
chunks.push(generateGenericServiceDefinition(ctx, fileDesc, sInfo, serviceDesc));
} 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));

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) => {
chunks.push(generateGrpcMethodDesc(ctx, serviceDesc, method));
if (method.serverStreaming) {
hasServerStreamingMethods = true;
} 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.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) => {
chunks.push(generateGrpcMethodDesc(ctx, serviceDesc, method));
if (method.serverStreaming) {
hasServerStreamingMethods = true;
}
});
}
});
}
}
});
}
serviceDesc.method.forEach((methodDesc, index) => {
if (methodDesc.serverStreaming || methodDesc.clientStreaming) {
Expand All @@ -241,7 +246,11 @@ export function generateFile(ctx: Context, fileDesc: FileDescriptorProto): [stri
});
});

if (options.outputServices === ServiceOption.DEFAULT && options.outputClientImpl && fileDesc.service.length > 0) {
if (
options.outputServices.includes(ServiceOption.DEFAULT) &&
options.outputClientImpl &&
fileDesc.service.length > 0
) {
if (options.outputClientImpl === true) {
chunks.push(generateRpcType(ctx, hasStreamingMethods));
} else if (options.outputClientImpl === 'grpc-web') {
Expand Down
15 changes: 12 additions & 3 deletions src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export type Options = {
constEnums: boolean;
enumsAsLiterals: boolean;
outputClientImpl: boolean | 'grpc-web';
outputServices: ServiceOption;
outputServices: ServiceOption[];
addGrpcMetadata: boolean;
addNestjsRestParameter: boolean;
returnObservable: boolean;
Expand Down Expand Up @@ -84,7 +84,7 @@ export function defaultOptions(): Options {
constEnums: false,
enumsAsLiterals: false,
outputClientImpl: true,
outputServices: ServiceOption.DEFAULT,
outputServices: [],
returnObservable: false,
addGrpcMetadata: false,
addNestjsRestParameter: false,
Expand Down Expand Up @@ -131,7 +131,16 @@ export function optionsFromParameter(parameter: string | undefined): Options {

// Treat outputServices=false as NONE
if ((options.outputServices as any) === false) {
options.outputServices = ServiceOption.NONE;
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) {
options.outputServices = [ServiceOption.DEFAULT];
}

if ((options.useDate as any) === true) {
Expand Down
2 changes: 1 addition & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ export class FormattedMethodDescriptor implements MethodDescriptorProto {
public static formatName(methodName: string, options: Options) {
let result = methodName;

if (options.lowerCaseServiceMethods || options.outputServices === ServiceOption.GRPC) {
if (options.lowerCaseServiceMethods || options.outputServices.includes(ServiceOption.GRPC)) {
result = camelCase(result);
}

Expand Down
8 changes: 5 additions & 3 deletions tests/options-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ describe('options', () => {
"outputJsonMethods": true,
"outputPartialMethods": false,
"outputSchema": false,
"outputServices": "default",
"outputServices": Array [
"default",
],
"outputTypeRegistry": false,
"returnObservable": false,
"snakeToCamel": Array [
Expand Down Expand Up @@ -61,14 +63,14 @@ describe('options', () => {
it('can set outputServices to false', () => {
const options = optionsFromParameter('outputServices=false');
expect(options).toMatchObject({
outputServices: ServiceOption.NONE,
outputServices: [ServiceOption.NONE],
});
});

it('can set outputServices to grpc', () => {
const options = optionsFromParameter('outputServices=grpc-js');
expect(options).toMatchObject({
outputServices: ServiceOption.GRPC,
outputServices: [ServiceOption.GRPC],
});
});

Expand Down

0 comments on commit 680831e

Please sign in to comment.