From 3d89282176f8f16a33eea5042df0439c3f23b038 Mon Sep 17 00:00:00 2001 From: Daniel Lytkin Date: Mon, 28 Jun 2021 21:07:23 +0700 Subject: [PATCH] feat: framework-agnostic service definitions (#316) * feat: generic service definitions * fix prettier * cleanup * simplify integration test * fix update-bins script * use outputServices=generic-definitions, update readme Co-authored-by: aikoven --- README.markdown | 2 + .../parameters.txt | 1 + .../generic-service-definitions/simple.bin | Bin 0 -> 1232 bytes .../generic-service-definitions/simple.proto | 25 +++ .../generic-service-definitions/simple.ts | 151 ++++++++++++++++++ src/generate-generic-service-definition.ts | 89 +++++++++++ src/main.ts | 3 + src/options.ts | 2 +- 8 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 integration/generic-service-definitions/parameters.txt create mode 100644 integration/generic-service-definitions/simple.bin create mode 100644 integration/generic-service-definitions/simple.proto create mode 100644 integration/generic-service-definitions/simple.ts create mode 100644 src/generate-generic-service-definition.ts diff --git a/README.markdown b/README.markdown index 917662c3c..11e27695b 100644 --- a/README.markdown +++ b/README.markdown @@ -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` diff --git a/integration/generic-service-definitions/parameters.txt b/integration/generic-service-definitions/parameters.txt new file mode 100644 index 000000000..fd04411a6 --- /dev/null +++ b/integration/generic-service-definitions/parameters.txt @@ -0,0 +1 @@ +outputServices=generic-definitions diff --git a/integration/generic-service-definitions/simple.bin b/integration/generic-service-definitions/simple.bin new file mode 100644 index 0000000000000000000000000000000000000000..75ce19bc3c6c9e3c221f076c4d2b5a0460af1b16 GIT binary patch literal 1232 zcmbW1-EPw`6vv(T*mX|&>6WFdG+4`!28c4zfe@2Gd<=<6?1I7rWUDtt>X*_m37&vU z-h?YIc{h%mI2sZ+-JO52|Gylc90jYZWVW1|;WAxhi=I-vS?{s|`~Ew?f3~#D4-W2P z_}Q$ok7l)sFOBY?xQeG=P1oD?_JdD3Jo&>B>?u7$@ns&T-*kIeyy#lE_rHe&re9%W zY|<-}j)Db9`}O&a>5Kd9PfysO!EpHVikpCzRgrr%@LU^jh-WNRPG(* z4r&Nja9nJ8mQ>`3VrqNOktBNj)DcP4v)6@$4hE&fDuYtu3!?2iMT}a=gWZRYND`D1 z0)&%di<1P^Ek`85XvYyr@b-O2R2Lq;aAYk`frXzY+)b5>> 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 { + 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 extends Builtin + ? T + : T extends Array + ? Array> + : T extends ReadonlyArray + ? ReadonlyArray> + : T extends {} + ? { [K in keyof T]?: DeepPartial } + : Partial; + +// If you get a compile-error about 'Constructor 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(); +} diff --git a/src/generate-generic-service-definition.ts b/src/generate-generic-service-definition.ts new file mode 100644 index 000000000..53d25cfff --- /dev/null +++ b/src/generate-generic-service-definition.ts @@ -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' }); +} diff --git a/src/main.ts b/src/main.ts index b5f9bbcb8..3f381aafa 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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; @@ -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. diff --git a/src/options.ts b/src/options.ts index 8116aa843..b33c6835f 100644 --- a/src/options.ts +++ b/src/options.ts @@ -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;