Skip to content

Commit

Permalink
feat: framework-agnostic service definitions (#316)
Browse files Browse the repository at this point in the history
* feat: generic service definitions

* fix prettier

* cleanup

* simplify integration test

* fix update-bins script

* use outputServices=generic-definitions, update readme

Co-authored-by: aikoven <dan.lytkin@gmail.com>
  • Loading branch information
aikoven and aikoven committed Jun 28, 2021
1 parent 939f1a6 commit 3d89282
Show file tree
Hide file tree
Showing 8 changed files with 272 additions and 1 deletion.
2 changes: 2 additions & 0 deletions README.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ protoc --plugin=node_modules/ts-proto/protoc-gen-ts_proto ./batching.proto -I.

- With `--ts_proto_opt=outputServices=grpc-js`, ts-proto will output service definitions and server / client stubs in [grpc-js](https://github.com/grpc/grpc-node/tree/master/packages/grpc-js) format.

- With `--ts_proto_opt=outputServices=generic-definitions`, ts-proto will output generic (framework-agnostic) 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`

Expand Down
1 change: 1 addition & 0 deletions integration/generic-service-definitions/parameters.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
outputServices=generic-definitions
Binary file not shown.
25 changes: 25 additions & 0 deletions integration/generic-service-definitions/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;
}
151 changes: 151 additions & 0 deletions integration/generic-service-definitions/simple.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/* eslint-disable */
import { util, configure, Writer, Reader } from 'protobufjs/minimal';
import * as Long from 'long';

export const protobufPackage = 'simple';

export interface TestMessage {
value: string;
}

const baseTestMessage: object = { 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 = { ...baseTestMessage } as TestMessage;
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 {
const message = { ...baseTestMessage } as TestMessage;
if (object.value !== undefined && object.value !== null) {
message.value = String(object.value);
} else {
message.value = '';
}
return message;
},

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

fromPartial(object: DeepPartial<TestMessage>): TestMessage {
const message = { ...baseTestMessage } as TestMessage;
if (object.value !== undefined && object.value !== null) {
message.value = object.value;
} else {
message.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>;

// 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();
}
89 changes: 89 additions & 0 deletions src/generate-generic-service-definition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { Code, code, def, joinCode } from 'ts-poet';
import {
FileDescriptorProto,
MethodDescriptorProto,
MethodOptions,
MethodOptions_IdempotencyLevel,
ServiceDescriptorProto,
} from 'ts-proto-descriptors';
import { camelCase } from './case';
import { Context } from './context';
import SourceInfo, { Fields } from './sourceInfo';
import { messageToTypeName } from './types';
import { maybeAddComment, maybePrefixPackage } from './utils';

/**
* Generates a framework-agnostic service descriptor.
*/
export function generateGenericServiceDefinition(
ctx: Context,
fileDesc: FileDescriptorProto,
sourceInfo: SourceInfo,
serviceDesc: ServiceDescriptorProto
) {
const chunks: Code[] = [];

maybeAddComment(sourceInfo, chunks, serviceDesc.options?.deprecated);

// Service definition
chunks.push(code`
export const ${def(`${serviceDesc.name}Definition`)} = {
`);

serviceDesc.options?.uninterpretedOption;
chunks.push(code`
name: '${serviceDesc.name}',
fullName: '${maybePrefixPackage(fileDesc, serviceDesc.name)}',
methods: {
`);

for (const [index, methodDesc] of serviceDesc.method.entries()) {
const info = sourceInfo.lookup(Fields.service.method, index);
maybeAddComment(info, chunks, methodDesc.options?.deprecated);

chunks.push(code`
${camelCase(methodDesc.name)}: ${generateMethodDefinition(ctx, methodDesc)},
`);
}

chunks.push(code`
},
} as const;
`);

return joinCode(chunks, { on: '\n' });
}

function generateMethodDefinition(ctx: Context, methodDesc: MethodDescriptorProto) {
const inputType = messageToTypeName(ctx, methodDesc.inputType, { keepValueType: true });
const outputType = messageToTypeName(ctx, methodDesc.outputType, { keepValueType: true });

return code`
{
name: '${methodDesc.name}',
requestType: ${inputType},
requestStream: ${methodDesc.clientStreaming},
responseType: ${outputType},
responseStream: ${methodDesc.serverStreaming},
options: ${generateMethodOptions(methodDesc.options)}
}
`;
}

function generateMethodOptions(options: MethodOptions | undefined) {
const chunks: Code[] = [];

chunks.push(code`{`);

if (options != null) {
if (options.idempotencyLevel === MethodOptions_IdempotencyLevel.IDEMPOTENT) {
chunks.push(code`idempotencyLevel: 'IDEMPOTENT',`);
} else if (options.idempotencyLevel === MethodOptions_IdempotencyLevel.NO_SIDE_EFFECTS) {
chunks.push(code`idempotencyLevel: 'NO_SIDE_EFFECTS',`);
}
}

chunks.push(code`}`);

return joinCode(chunks, { on: '\n' });
}
3 changes: 3 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import { Context } from './context';
import { generateSchema } from './schema';
import { ConditionalOutput } from 'ts-poet/build/ConditionalOutput';
import { generateGrpcJsService } from './generate-grpc-js';
import { generateGenericServiceDefinition } from './generate-generic-service-definition';

export function generateFile(ctx: Context, fileDesc: FileDescriptorProto): [string, Code] {
const { options, utils } = ctx;
Expand Down Expand Up @@ -176,6 +177,8 @@ export function generateFile(ctx: Context, fileDesc: FileDescriptorProto): [stri
chunks.push(code`export const ${serviceConstName} = "${serviceDesc.name}";`);
} else if (options.outputServices === 'grpc-js') {
chunks.push(generateGrpcJsService(ctx, fileDesc, sInfo, serviceDesc));
} else if (options.outputServices === 'generic-definitions') {
chunks.push(generateGenericServiceDefinition(ctx, fileDesc, sInfo, serviceDesc));
} else {
// 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.
Expand Down
2 changes: 1 addition & 1 deletion src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export type Options = {
stringEnums: boolean;
constEnums: boolean;
outputClientImpl: boolean | 'grpc-web';
outputServices: false | 'grpc-js';
outputServices: false | 'grpc-js' | 'generic-definitions';
addGrpcMetadata: boolean;
addNestjsRestParameter: boolean;
returnObservable: boolean;
Expand Down

0 comments on commit 3d89282

Please sign in to comment.