From 2478a44c13668d8865164c7ddc5d9bfdacab0506 Mon Sep 17 00:00:00 2001 From: Lewis Nelson Date: Thu, 26 Jan 2023 23:43:07 +0000 Subject: [PATCH] feat: generating new clients implementing GenericClient on generate command --- README.md | 34 +- examples/README.md | 3 + examples/generated/example/index.d.ts | 20 + examples/generated/example/index.js | 31 ++ examples/generated_client/README.md | 5 + .../bidirectionalStreaming.ts | 11 +- .../{ => generated_client}/clientStreaming.ts | 13 +- .../{ => generated_client}/serverStreaming.ts | 11 +- .../{ => generated_client}/unaryRequests.ts | 11 +- jest.config.js | 11 + package.json | 2 +- src/cli/commands/generate.ts | 98 +++-- src/cli/lib/clientGenerator.ts | 373 ++++++++++++++++++ src/genericClient.ts | 4 +- src/types.ts | 42 ++ test/cli/generate.test.ts | 166 +++++++- test/e2e/users.service.test.ts | 70 ++-- test/{cli => }/protos/accounts/events.proto | 0 .../protos/accounts/infra/monitoring.proto | 0 test/{cli => }/protos/accounts/users.proto | 0 test/{cli => }/protos/events.proto | 0 test/{cli => }/protos/logger.proto | 0 test/{cli => }/protos/people.proto | 0 test/tsconfig.json | 9 + 24 files changed, 770 insertions(+), 144 deletions(-) create mode 100644 examples/README.md create mode 100644 examples/generated/example/index.d.ts create mode 100644 examples/generated/example/index.js create mode 100644 examples/generated_client/README.md rename examples/{ => generated_client}/bidirectionalStreaming.ts (78%) rename examples/{ => generated_client}/clientStreaming.ts (82%) rename examples/{ => generated_client}/serverStreaming.ts (79%) rename examples/{ => generated_client}/unaryRequests.ts (72%) create mode 100644 src/cli/lib/clientGenerator.ts rename test/{cli => }/protos/accounts/events.proto (100%) rename test/{cli => }/protos/accounts/infra/monitoring.proto (100%) rename test/{cli => }/protos/accounts/users.proto (100%) rename test/{cli => }/protos/events.proto (100%) rename test/{cli => }/protos/logger.proto (100%) rename test/{cli => }/protos/people.proto (100%) create mode 100644 test/tsconfig.json diff --git a/README.md b/README.md index a9f452c..efca2f4 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ client.getName(new GetNameRequest(), (error: Error | null, response: GetNameResp to this: ```typescript -const response = await client.unaryRequest("getName", new GetNameRequest()); +const response = await client.getName(new GetNameRequest()); ``` ## Install @@ -50,41 +50,17 @@ The generator uses [grpc-tools](https://github.com/grpc/grpc-node/tree/master/pa ```typescript import * as grpc from "@grpc/grpc-js"; -import { createClient } from "@lewnelson/grpc-ts"; -import { GeneratedClient } from "./path/to/output/generated/generated_grpc_pb"; +import { GeneratedClient } from "./path/to/output/generated/client"; // Creating a client with insecure credentials -const client = createClient(GeneratedClient, "localhost:50051"); +const client = new GeneratedClient("localhost:50051"); // Creating a client with SSL credentials -const client = createClient( - GeneratedClient, +const client = new GeneratedClient( "localhost:50051", { credentials: grpc.credentials.createSsl() } ); ``` -### Making unary requests - -All unary requests are available on `client.unaryRequest`. The first argument is the name of the method to call. Only methods which return a `grpc.ClientUnaryCall` are available on `client.unaryRequest`. The second argument is the request object. This maps to the method name. - -See [examples/unaryRequests.ts](./examples/unaryRequests.ts) for examples. - -### Making server streaming requests - -All server streaming requests are available on `client.serverStreamRequest`. The first argument is the name of the method to call. Only methods which return a `grpc.ClientReadableStream` are available on `client.serverStreamRequest`. The second argument is the request object. This maps to the method name. - -See [examples/serverStreaming.ts](./examples/serverStreaming.ts) for examples. - -### Making client streaming requests - -All client streaming requests are available on `client.clientStreamRequest`. The first argument is the name of the method to call. Only methods which return a `grpc.ClientWritableStream` are available on `client.clientStreamRequest`. The second argument is the callback when the server makes the unary response. - -See [examples/clientStreaming.ts](./examples/clientStreaming.ts) for examples. - -### Making bidirectional streaming requests - -All bidirectional streaming requests are available on `client.duplexStreamRequest`. The first argument is the name of the method to call. Only methods which return a `grpc.ClientDuplexStream` are available on `client.duplexStreamRequest`. Optionally you can bind a callback to read the server stream when calling the `client.duplexStreamRequest` function. - -See [examples/bidirectionalStreaming.ts](./examples/bidirectionalStreaming.ts) for examples. +For full usage see [Examples](./examples/README.md). \ No newline at end of file diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..34c87b6 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,3 @@ +# Examples + +- [Using the generated client](./generated_client/README.md) diff --git a/examples/generated/example/index.d.ts b/examples/generated/example/index.d.ts new file mode 100644 index 0000000..6fa3255 --- /dev/null +++ b/examples/generated/example/index.d.ts @@ -0,0 +1,20 @@ +// This file was generated using @lewnelson/grpc-ts +// Do not modify this file directly + +import { createClient, UnaryRequest, ReadableStreamRequest, WriteableStreamRequest, DuplexStreamRequest } from "@lewnelson/grpc-ts"; +import { ExampleClient as grpc_ExampleClient } from "./example_grpc_pb"; + +export interface IExampleClient { + getName: UnaryRequest; + streamMessages: ReadableStreamRequest; + emitMessages: WriteableStreamRequest; + chat: DuplexStreamRequest; +} + +export class ExampleClient implements IExampleClient { + constructor(address: Parameters[1], options?: Parameters[2]); + getName: UnaryRequest; + streamMessages: ReadableStreamRequest; + emitMessages: WriteableStreamRequest; + chat: DuplexStreamRequest; +} diff --git a/examples/generated/example/index.js b/examples/generated/example/index.js new file mode 100644 index 0000000..eb68f04 --- /dev/null +++ b/examples/generated/example/index.js @@ -0,0 +1,31 @@ +// This file was generated using @lewnelson/grpc-ts +// Do not modify this file directly + +const { createClient } = require("@lewnelson/grpc-ts"); +const grpc = require("./example_grpc_pb"); + +class ExampleClient { + constructor() { + this.client = createClient(grpc.ExampleClient, ...arguments); + } + + getName() { + return this.client.unaryRequest("getName", ...arguments); + } + + streamMessages() { + return this.client.serverStreamRequest("streamMessages", ...arguments); + } + + emitMessages() { + return this.client.clientStreamRequest("emitMessages", ...arguments); + } + + chat() { + return this.client.duplexStreamRequest("chat", ...arguments); + } +} + +exports.ExampleClient = ExampleClient; + + \ No newline at end of file diff --git a/examples/generated_client/README.md b/examples/generated_client/README.md new file mode 100644 index 0000000..50c4043 --- /dev/null +++ b/examples/generated_client/README.md @@ -0,0 +1,5 @@ +# Generated client examples + +The `grpc-ts` CLI tool will generate the output from [grpc-tools](https://www.npmjs.com/package/grpc-tools) as well as creating a client class which uses the [GenericClient](../generic_client/README.md) under the hood to make requests. + +The client class method names are a 1:1 mapping of the methods defined in the generated client class on the `_grpc_pb.d.ts` files. diff --git a/examples/bidirectionalStreaming.ts b/examples/generated_client/bidirectionalStreaming.ts similarity index 78% rename from examples/bidirectionalStreaming.ts rename to examples/generated_client/bidirectionalStreaming.ts index e0e8c26..f4e3c1c 100644 --- a/examples/bidirectionalStreaming.ts +++ b/examples/generated_client/bidirectionalStreaming.ts @@ -1,13 +1,12 @@ -import { createClient } from "@lewnelson/grpc-ts"; -import { ExampleClient } from "./generated/example/example_grpc_pb"; -import { ChatRequest, ChatResponse } from "./generated/example/example_pb"; +import { ExampleClient } from "../generated/example"; +import { ChatRequest, ChatResponse } from "../generated/example/example_pb"; // Creates client with insecure credentials -const client = createClient(ExampleClient, "localhost:50051"); +const client = new ExampleClient("localhost:50051"); // Basic implementation (async () => { - const stream = client.duplexStreamRequest("chat", { + const stream = client.chat({ onData: ( complete: boolean, error: Error | null, @@ -48,7 +47,7 @@ const client = createClient(ExampleClient, "localhost:50051"); // Advanced usage (async () => { // Create the stream without binding a callback - const stream = client.duplexStreamRequest("chat"); + const stream = client.chat(); // Access the underlying grpc-js Duplex stream stream._stream.on("data", (chunk: unknown) => { diff --git a/examples/clientStreaming.ts b/examples/generated_client/clientStreaming.ts similarity index 82% rename from examples/clientStreaming.ts rename to examples/generated_client/clientStreaming.ts index de8f946..de89f21 100644 --- a/examples/clientStreaming.ts +++ b/examples/generated_client/clientStreaming.ts @@ -1,18 +1,16 @@ import { credentials, Metadata, ServiceError } from "@grpc/grpc-js"; -import { createClient } from "@lewnelson/grpc-ts"; -import { ExampleClient } from "./generated/example/example_grpc_pb"; +import { ExampleClient } from "../generated/example"; import { EmitMessagesRequest, EmitMessagesResponse, -} from "./generated/example/example_pb"; +} from "../generated/example/example_pb"; // Creates client with insecure credentials -const client = createClient(ExampleClient, "localhost:50051"); +const client = new ExampleClient("localhost:50051"); // Making a basic request (async () => { - const stream = client.clientStreamRequest( - "emitMessages", + const stream = client.emitMessages( (error: ServiceError | null, response?: EmitMessagesResponse) => { if (error) { // Server responded with an error @@ -39,8 +37,7 @@ const client = createClient(ExampleClient, "localhost:50051"); // Advanced usage (async () => { - const stream = client.clientStreamRequest( - "emitMessages", + const stream = client.emitMessages( (error: ServiceError | null, response?: EmitMessagesResponse) => { if (error) { // Server responded with an error diff --git a/examples/serverStreaming.ts b/examples/generated_client/serverStreaming.ts similarity index 79% rename from examples/serverStreaming.ts rename to examples/generated_client/serverStreaming.ts index 25959e3..4bf96b9 100644 --- a/examples/serverStreaming.ts +++ b/examples/generated_client/serverStreaming.ts @@ -1,13 +1,12 @@ import { Metadata } from "@grpc/grpc-js"; -import { createClient } from "@lewnelson/grpc-ts"; -import { ExampleClient } from "./generated/example/example_grpc_pb"; +import { ExampleClient } from "../generated/example"; import { StreamMessagesRequest, StreamMessagesResponse, -} from "./generated/example/example_pb"; +} from "../generated/example/example_pb"; // Creates client with insecure credentials -const client = createClient(ExampleClient, "localhost:50051"); +const client = new ExampleClient("localhost:50051"); // Making a basic request (() => { @@ -16,7 +15,7 @@ const client = createClient(ExampleClient, "localhost:50051"); request.setId("id"); // Make the request - client.serverStreamRequest("streamMessages", request, { + client.streamMessages(request, { onData: ( complete: boolean, error: Error | null, @@ -48,7 +47,7 @@ const client = createClient(ExampleClient, "localhost:50051"); request.setId("id"); // Make the request specifying metadata and call options - const stream = client.serverStreamRequest("streamMessages", request, { + const stream = client.streamMessages(request, { metadata: new Metadata({ cacheableRequest: true }), options: { deadline: new Date(Date.now() + 10e3) }, }); diff --git a/examples/unaryRequests.ts b/examples/generated_client/unaryRequests.ts similarity index 72% rename from examples/unaryRequests.ts rename to examples/generated_client/unaryRequests.ts index 3c0bfe4..0c6274f 100644 --- a/examples/unaryRequests.ts +++ b/examples/generated_client/unaryRequests.ts @@ -1,10 +1,9 @@ -import { createClient } from "@lewnelson/grpc-ts"; import { Metadata, ServiceError } from "@grpc/grpc-js"; -import { ExampleClient } from "./generated/example/example_grpc_pb"; -import { GetNameRequest } from "./generated/example/example_pb"; +import { ExampleClient } from "../generated/example"; +import { GetNameRequest } from "../generated/example/example_pb"; // Creates client with insecure credentials -const client = createClient(ExampleClient, "localhost:50051"); +const client = new ExampleClient("localhost:50051"); // Making a basic request (async () => { @@ -14,7 +13,7 @@ const client = createClient(ExampleClient, "localhost:50051"); try { // Make the request - const response = await client.unaryRequest("getName", request); + const response = await client.getName(request); // Do something with the response response.getName(); } catch (error) { @@ -35,7 +34,7 @@ const client = createClient(ExampleClient, "localhost:50051"); try { // Make the request - const response = await client.unaryRequest("getName", request, { + const response = await client.getName(request, { metadata: new Metadata({ cacheableRequest: true }), options: { // Specify a deadline on the request 10 seconds from now diff --git a/jest.config.js b/jest.config.js index 66d151a..ec6bc2b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,9 +1,20 @@ /** @type {import("ts-jest").JestConfigWithTsJest} */ module.exports = { preset: "ts-jest", + transform: { + "^.+\\.ts$": [ + "ts-jest", + { + tsconfig: "/test/tsconfig.json", + }, + ], + }, testEnvironment: "node", setupFilesAfterEnv: ["./jest.setup.ts"], collectCoverage: true, coverageDirectory: "./coverage", coveragePathIgnorePatterns: ["/node_modules/", "/generated/"], + moduleNameMapper: { + "^@lewnelson/grpc-ts$": "/src/index.ts", + }, }; diff --git a/package.json b/package.json index 7637b24..e67e128 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "clean": "npm run clean:cli && npm run clean:dist", "clean:cli": "rimraf cli", "clean:dist": "rimraf dist", - "generate:test": "rimraf test/generated && node ./cli/index.js generate test/protos test/generated", + "generate:test": "rimraf test/generated && node ./cli/index.js generate test/protos/users.proto test/generated", "lint": "eslint .", "lint:fix": "npm run lint --fix", "prepare": "npx install-peers && npx husky install", diff --git a/src/cli/commands/generate.ts b/src/cli/commands/generate.ts index 120f8a3..b993adb 100644 --- a/src/cli/commands/generate.ts +++ b/src/cli/commands/generate.ts @@ -2,7 +2,8 @@ import { Command } from "commander"; import mkdirp from "mkdirp"; import execa from "execa"; import { basename, extname, join, resolve } from "path"; -import { stat, readdir, getRootDirectory } from "../utils"; +import { stat, readdir, getRootDirectory, writeFile } from "../utils"; +import { generateClientDeclarationAndJavaScript } from "../lib/clientGenerator"; type GenerateOptions = { input: string; @@ -71,6 +72,19 @@ const getInputFiles = async ({ } }; +const getClientOutputDirectory = ( + destination: string, + inputFile: InputFile +): string => { + const currentDirectory = process.cwd(); + return resolve( + currentDirectory, + destination, + inputFile.relativePath, + basename(inputFile.filename, ".proto") + ); +}; + const generateForFile = async ({ inputFile, destination, @@ -80,14 +94,7 @@ const generateForFile = async ({ destination: string; rootDirectory: string; }): Promise => { - const currentDirectory = process.cwd(); - const outDirectory = resolve( - currentDirectory, - destination, - inputFile.relativePath, - basename(inputFile.filename, ".proto") - ); - + const outDirectory = getClientOutputDirectory(destination, inputFile); const plugin = join(rootDirectory, "node_modules", ".bin", "protoc-gen-ts"); const executable = join( rootDirectory, @@ -97,30 +104,49 @@ const generateForFile = async ({ ); await mkdirp(outDirectory); - try { - console.log(`Generating code for ${inputFile.path}`); - const protocProcess = execa( - executable, - [ - `--plugin=protoc-gen-ts=${plugin}`, - `--js_out=import_style=commonjs,binary:./`, - `--ts_out=service=grpc-node,mode=grpc-js:./`, - `--grpc_out=grpc_js:./`, - `--proto_path=${resolve(inputFile.path, "..")}`, - inputFile.filename, - ], - { - cwd: outDirectory, - } - ); + const protocProcess = execa( + executable, + [ + `--plugin=protoc-gen-ts=${plugin}`, + `--js_out=import_style=commonjs,binary:./`, + `--ts_out=service=grpc-node,mode=grpc-js:./`, + `--grpc_out=grpc_js:./`, + `--proto_path=${resolve(inputFile.path, "..")}`, + inputFile.filename, + ], + { + cwd: outDirectory, + } + ); + + protocProcess.stdout?.pipe(process.stdout); + protocProcess.stderr?.pipe(process.stderr); + await protocProcess; +}; + +const generateClient = async ({ + inputFile, + destination, +}: { + inputFile: InputFile; + destination: string; +}) => { + const grpcPbDefintionsFile = join( + getClientOutputDirectory(destination, inputFile), + `${basename(inputFile.filename, ".proto")}_grpc_pb.d.ts` + ); - protocProcess.stdout?.pipe(process.stdout); - protocProcess.stderr?.pipe(process.stderr); - await protocProcess; - console.log(`Successfully generated code for ${inputFile.path}`); - } catch (error) { - console.error(`Error generating for file ${inputFile.path}`); + const output = await generateClientDeclarationAndJavaScript( + grpcPbDefintionsFile + ); + + if (!output) { + throw new Error("Error occurred generating client"); } + + const outputDirectory = getClientOutputDirectory(destination, inputFile); + await writeFile(join(outputDirectory, "index.d.ts"), output.declaration); + await writeFile(join(outputDirectory, "index.js"), output.javascript); }; const onGenerate = async ({ @@ -131,8 +157,14 @@ const onGenerate = async ({ const inputFiles = await getInputFiles({ input, recursive }); const rootDirectory = await getRootDirectory(); await Promise.all( - inputFiles.map((inputFile) => { - return generateForFile({ inputFile, destination, rootDirectory }); + inputFiles.map(async (inputFile) => { + try { + await generateForFile({ inputFile, destination, rootDirectory }); + await generateClient({ inputFile, destination }); + console.log(`Successfully generated output for: ${inputFile.path}`); + } catch (error) { + console.log(`Failed to generate output for: ${inputFile.path}`); + } }) ); }; diff --git a/src/cli/lib/clientGenerator.ts b/src/cli/lib/clientGenerator.ts new file mode 100644 index 0000000..fdfa589 --- /dev/null +++ b/src/cli/lib/clientGenerator.ts @@ -0,0 +1,373 @@ +import ts from "typescript"; +import { basename } from "path"; + +enum MethodType { + unary = "unary", + readableStream = "readableStream", + writeableStream = "writeableStream", + duplexStream = "duplexStream", +} + +type ClientClassWithMethods = { + name: string; + methods: { + methodName: string; + type: MethodType; + }[]; +}; + +const getGenericMethod = (type: MethodType) => { + switch (type) { + case MethodType.unary: + return "unaryRequest"; + case MethodType.readableStream: + return "serverStreamRequest"; + case MethodType.writeableStream: + return "clientStreamRequest"; + case MethodType.duplexStream: + return "duplexStreamRequest"; + } +}; + +const getJavaScriptOutput = ( + clientClasses: ClientClassWithMethods[], + grpcPbPath: string +) => { + const grpcPbImport = basename(grpcPbPath, ".d.ts"); + return `const { createClient } = require("@lewnelson/grpc-ts"); +const grpc = require("./${grpcPbImport}"); + +${clientClasses + .map((clientClass) => { + return `class ${clientClass.name} { + constructor() { + this.client = createClient(grpc.${clientClass.name}, ...arguments); + } + + ${clientClass.methods + .map(({ methodName, type }) => { + return `${methodName}() { + return this.client.${getGenericMethod(type)}("${methodName}", ...arguments); + }`; + }) + .join("\n\n ")} +} + +exports.${clientClass.name} = ${clientClass.name}; +`; + }) + .join("\n\n")} + `; +}; + +export const generateClientDeclarationAndJavaScript = async ( + grpcPbPath: string +) => { + const program = ts.createProgram([grpcPbPath], {}); + const source = program.getSourceFile(grpcPbPath); + if (!source) return; + + const createImport = ( + imports: ({ original?: string; name: string } | string)[], + from: string + ) => { + return ts.factory.createImportDeclaration( + undefined, + ts.factory.createImportClause( + false, + undefined, + ts.factory.createNamedImports( + imports.map((importDefinition) => { + let original: string | undefined; + let name: string; + if (typeof importDefinition === "string") { + name = importDefinition; + } else { + original = importDefinition.original; + name = importDefinition.name; + } + + return ts.factory.createImportSpecifier( + false, + original ? ts.factory.createIdentifier(original) : undefined, + ts.factory.createIdentifier(name) + ); + }) + ) + ), + ts.factory.createStringLiteral(from) + ); + }; + + const hasSomeOf = (classes: ClientClassWithMethods[], type: MethodType) => { + return classes.some((clientClass) => + clientClass.methods.some((method) => method.type === type) + ); + }; + + const getRequestType = (type: MethodType): string => { + switch (type) { + case MethodType.unary: + return "UnaryRequest"; + case MethodType.readableStream: + return "ReadableStreamRequest"; + case MethodType.writeableStream: + return "WriteableStreamRequest"; + case MethodType.duplexStream: + return "DuplexStreamRequest"; + } + }; + + const createInterface = (clientClass: ClientClassWithMethods) => { + const members = clientClass.methods.map(({ methodName, type }) => { + return ts.factory.createPropertySignature( + undefined, + methodName, + undefined, + ts.factory.createTypeReferenceNode(getRequestType(type), [ + ts.factory.createTypeReferenceNode( + `grpc_${clientClass.name}`, + undefined + ), + ts.factory.createLiteralTypeNode( + ts.factory.createStringLiteral(methodName) + ), + ]) + ); + }); + + return ts.factory.createInterfaceDeclaration( + ts.factory.createModifiersFromModifierFlags(ts.ModifierFlags.Export), + `I${clientClass.name}`, + undefined, + undefined, + members + ); + }; + + const createClass = (clientClass: ClientClassWithMethods) => { + const members = clientClass.methods.map(({ methodName, type }) => { + return ts.factory.createPropertyDeclaration( + undefined, + methodName, + undefined, + ts.factory.createTypeReferenceNode(getRequestType(type), [ + ts.factory.createTypeReferenceNode( + `grpc_${clientClass.name}`, + undefined + ), + ts.factory.createLiteralTypeNode( + ts.factory.createStringLiteral(methodName) + ), + ]), + undefined + ); + }); + + return ts.factory.createClassDeclaration( + ts.factory.createModifiersFromModifierFlags(ts.ModifierFlags.Export), + clientClass.name, + undefined, + [ + ts.factory.createHeritageClause(ts.SyntaxKind.ImplementsKeyword, [ + ts.factory.createExpressionWithTypeArguments( + ts.factory.createIdentifier(`I${clientClass.name}`), + undefined + ), + ]), + ], + [ + ts.factory.createConstructorDeclaration( + undefined, + [ + ts.factory.createParameterDeclaration( + undefined, + undefined, + "address", + undefined, + ts.factory.createIndexedAccessTypeNode( + ts.factory.createTypeReferenceNode("Parameters", [ + ts.factory.createTypeQueryNode( + ts.factory.createIdentifier("createClient") + ), + ]), + ts.factory.createLiteralTypeNode( + ts.factory.createNumericLiteral("1") + ) + ) + ), + ts.factory.createParameterDeclaration( + undefined, + undefined, + "options", + ts.factory.createToken(ts.SyntaxKind.QuestionToken), + ts.factory.createIndexedAccessTypeNode( + ts.factory.createTypeReferenceNode("Parameters", [ + ts.factory.createTypeQueryNode( + ts.factory.createIdentifier("createClient") + ), + ]), + ts.factory.createLiteralTypeNode( + ts.factory.createNumericLiteral("2") + ) + ) + ), + ], + undefined + ), + ...members, + ] + ); + }; + + const extendsGrpcClient = (node: ts.ClassLikeDeclaration) => { + return node.heritageClauses?.some((clause) => { + return clause.types.some((type) => { + return type.expression.getText(source) === "grpc.Client"; + }); + }); + }; + + const isClientClassNode = ( + node: ts.Node + ): node is ts.ClassLikeDeclaration => { + if (!ts.isClassLike(node)) return false; + if (!extendsGrpcClient(node)) return false; + return true; + }; + + const isMethodReturningType = + (returnType: string) => (node: ts.MethodDeclaration) => { + if (!node.type || !ts.isTypeReferenceNode(node.type)) return false; + return node.type.typeName.getText(source) === returnType; + }; + + const getMethodsMatchingReturnType = ( + node: ts.ClassLikeDeclaration, + returnType: string + ) => { + const matchesReturnType = isMethodReturningType(returnType); + return node.members.reduce((methods, member) => { + if (!ts.isMethodDeclaration(member)) return methods; + if (!matchesReturnType(member)) return methods; + if ( + methods.some( + (existingMethod) => + existingMethod.name.getText(source) === member.name.getText(source) + ) + ) { + return methods; + } + + return [...methods, member]; + }, [] as ts.MethodDeclaration[]); + }; + + const findClientClassNodes = () => { + const classes: ClientClassWithMethods[] = []; + ts.forEachChild(source, (node) => { + if (!isClientClassNode(node)) return; + if (!node.name) return; + classes.push({ + name: node.name.getText(source), + methods: [ + ...( + getMethodsMatchingReturnType(node, "grpc.ClientUnaryCall") || [] + ).map((method) => ({ + methodName: method.name.getText(source), + type: MethodType.unary, + })), + ...( + getMethodsMatchingReturnType(node, "grpc.ClientReadableStream") || + [] + ).map((method) => ({ + methodName: method.name.getText(source), + type: MethodType.readableStream, + })), + ...( + getMethodsMatchingReturnType(node, "grpc.ClientWritableStream") || + [] + ).map((method) => ({ + methodName: method.name.getText(source), + type: MethodType.writeableStream, + })), + ...( + getMethodsMatchingReturnType(node, "grpc.ClientDuplexStream") || [] + ).map((method) => ({ + methodName: method.name.getText(source), + type: MethodType.duplexStream, + })), + ], + }); + }); + + return classes; + }; + + const clientClasses = findClientClassNodes(); + const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); + + const grpcTSImports = ["createClient"]; + const grpcImportsMap: [MethodType, string][] = [ + [MethodType.unary, "UnaryRequest"], + [MethodType.readableStream, "ReadableStreamRequest"], + [MethodType.writeableStream, "WriteableStreamRequest"], + [MethodType.duplexStream, "DuplexStreamRequest"], + ]; + + grpcImportsMap.forEach(([type, importName]) => { + if (hasSomeOf(clientClasses, type)) { + grpcTSImports.push(importName); + } + }); + + const genericImportClause = createImport(grpcTSImports, "@lewnelson/grpc-ts"); + const statements: ts.Statement[][] = [[genericImportClause]]; + + const pushToStatement = ( + newline: boolean, + ...statementsToPush: ts.Statement[] + ) => { + if (newline) { + statements.push([...statementsToPush]); + } else { + statements[statements.length - 1].push(...statementsToPush); + } + }; + + const classImports = clientClasses.map((clientClass) => ({ + original: clientClass.name, + name: `grpc_${clientClass.name}`, + })); + + pushToStatement( + false, + createImport(classImports, `./${basename(grpcPbPath, ".d.ts")}`) + ); + + clientClasses.forEach((clientClass) => { + pushToStatement(true, createInterface(clientClass)); + pushToStatement(true, createClass(clientClass)); + }); + + const declarationOutput = statements + .map((statements) => { + const source = ts.factory.createSourceFile( + statements, + ts.factory.createToken(ts.SyntaxKind.EndOfFileToken), + ts.NodeFlags.None + ); + + return printer.printFile(source); + }) + .join("\n"); + + const javascriptOutput = getJavaScriptOutput(clientClasses, grpcPbPath); + const fileComment = + "// This file was generated using @lewnelson/grpc-ts\n// Do not modify this file directly\n\n"; + + return { + declaration: `${fileComment}${declarationOutput}`, + javascript: `${fileComment}${javascriptOutput}`, + }; +}; diff --git a/src/genericClient.ts b/src/genericClient.ts index 727c384..d5a60c2 100644 --- a/src/genericClient.ts +++ b/src/genericClient.ts @@ -9,7 +9,6 @@ import { UnaryGrpcFunction, ReadableStreamFunctions, ReadableStreamRequestType, - ReadableStreamResponseType, ReadableStreamGrpcFunction, WriteableStreamFunctions, WriteableStreamGrpcFunction, @@ -22,6 +21,7 @@ import { OnData, WriteAsyncCallback, WriteStreamOnResponse, + ReadStreamReturnType, } from "./types"; /** @@ -126,7 +126,7 @@ export class GenericClient { fn: TFn, request: ReadableStreamRequestType, callOptions: GrpcReadStreamCall = {} - ): grpc.ClientReadableStream> { + ): ReadStreamReturnType { const { metadata, options, onData } = callOptions; const stream = ( this.client[fn] as ReadableStreamGrpcFunction diff --git a/src/types.ts b/src/types.ts index a980a1e..d7d56f1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -263,11 +263,21 @@ export type WriteStreamOnResponse< response: WriteableStreamResponseType ) => void; +export type UnaryRequestReturnType< + TGrpcClient, + TFn extends keyof UnaryGrpcFunctions +> = Promise>>; + export type WriteStreamReturnType< TGrpcClient, TFn extends keyof WriteableStreamFunctions > = WriteStream; +export type ReadStreamReturnType< + TGrpcClient, + TFn extends keyof ReadableStreamFunctions +> = grpc.ClientReadableStream>; + export type ReadWriteStreamReturnType< TGrpcClient, TFn extends keyof DuplexStreamFunctions @@ -283,3 +293,35 @@ export type ReadWriteStreamReturnType< export type WriteAsyncCallback = ( chunk: TRequestType ) => Promise; + +// Generated client types +export type UnaryRequest< + TGrpcClient, + TFn extends keyof UnaryGrpcFunctions +> = ( + requestType: UnaryRequestType, + callOptions?: GrpcCall +) => UnaryRequestReturnType; + +export type ReadableStreamRequest< + TGrpcClient, + TFn extends keyof ReadableStreamFunctions +> = ( + requestType: ReadableStreamRequestType, + callOptions?: GrpcReadStreamCall +) => ReadStreamReturnType; + +export type WriteableStreamRequest< + TGrpcClient, + TFn extends keyof WriteableStreamFunctions +> = ( + onResponse: WriteStreamOnResponse, + callOptions?: GrpcCall +) => WriteStreamReturnType; + +export type DuplexStreamRequest< + TGrpcClient, + TFn extends keyof DuplexStreamFunctions +> = ( + callOptions?: GrpcReadWriteStreamCall +) => ReadWriteStreamReturnType; diff --git a/test/cli/generate.test.ts b/test/cli/generate.test.ts index 15be74e..76cfe8d 100644 --- a/test/cli/generate.test.ts +++ b/test/cli/generate.test.ts @@ -65,7 +65,7 @@ describe("'grpc-ts generate' cli command", () => { let filesystemTree: FilesystemTree; describe("when specifying a directory", () => { beforeAll(async () => { - await runCommand("./protos", "./generated", true); + await runCommand("../protos", "./generated", true); filesystemTree = getFilesystemTree(generatedDirectory); }); @@ -96,6 +96,14 @@ describe("'grpc-ts generate' cli command", () => { "name": "events_pb.js", "type": "file", }, + { + "name": "index.d.ts", + "type": "file", + }, + { + "name": "index.js", + "type": "file", + }, ], "name": "events", "type": "directory", @@ -104,6 +112,14 @@ describe("'grpc-ts generate' cli command", () => { "children": [ { "children": [ + { + "name": "index.d.ts", + "type": "file", + }, + { + "name": "index.js", + "type": "file", + }, { "name": "monitoring_grpc_pb.d.ts", "type": "file", @@ -130,6 +146,14 @@ describe("'grpc-ts generate' cli command", () => { }, { "children": [ + { + "name": "index.d.ts", + "type": "file", + }, + { + "name": "index.js", + "type": "file", + }, { "name": "users_grpc_pb.d.ts", "type": "file", @@ -172,12 +196,28 @@ describe("'grpc-ts generate' cli command", () => { "name": "events_pb.js", "type": "file", }, + { + "name": "index.d.ts", + "type": "file", + }, + { + "name": "index.js", + "type": "file", + }, ], "name": "events", "type": "directory", }, { "children": [ + { + "name": "index.d.ts", + "type": "file", + }, + { + "name": "index.js", + "type": "file", + }, { "name": "logger_grpc_pb.d.ts", "type": "file", @@ -200,6 +240,14 @@ describe("'grpc-ts generate' cli command", () => { }, { "children": [ + { + "name": "index.d.ts", + "type": "file", + }, + { + "name": "index.js", + "type": "file", + }, { "name": "people_grpc_pb.d.ts", "type": "file", @@ -220,6 +268,36 @@ describe("'grpc-ts generate' cli command", () => { "name": "people", "type": "directory", }, + { + "children": [ + { + "name": "index.d.ts", + "type": "file", + }, + { + "name": "index.js", + "type": "file", + }, + { + "name": "users_grpc_pb.d.ts", + "type": "file", + }, + { + "name": "users_grpc_pb.js", + "type": "file", + }, + { + "name": "users_pb.d.ts", + "type": "file", + }, + { + "name": "users_pb.js", + "type": "file", + }, + ], + "name": "users", + "type": "directory", + }, ] `); }); @@ -227,7 +305,7 @@ describe("'grpc-ts generate' cli command", () => { describe("when specifying a directory and setting recursive to false", () => { beforeAll(async () => { - await runCommand("./protos", "./generated", false); + await runCommand("../protos", "./generated", false); filesystemTree = getFilesystemTree(generatedDirectory); }); @@ -256,12 +334,28 @@ describe("'grpc-ts generate' cli command", () => { "name": "events_pb.js", "type": "file", }, + { + "name": "index.d.ts", + "type": "file", + }, + { + "name": "index.js", + "type": "file", + }, ], "name": "events", "type": "directory", }, { "children": [ + { + "name": "index.d.ts", + "type": "file", + }, + { + "name": "index.js", + "type": "file", + }, { "name": "logger_grpc_pb.d.ts", "type": "file", @@ -284,6 +378,14 @@ describe("'grpc-ts generate' cli command", () => { }, { "children": [ + { + "name": "index.d.ts", + "type": "file", + }, + { + "name": "index.js", + "type": "file", + }, { "name": "people_grpc_pb.d.ts", "type": "file", @@ -304,6 +406,36 @@ describe("'grpc-ts generate' cli command", () => { "name": "people", "type": "directory", }, + { + "children": [ + { + "name": "index.d.ts", + "type": "file", + }, + { + "name": "index.js", + "type": "file", + }, + { + "name": "users_grpc_pb.d.ts", + "type": "file", + }, + { + "name": "users_grpc_pb.js", + "type": "file", + }, + { + "name": "users_pb.d.ts", + "type": "file", + }, + { + "name": "users_pb.js", + "type": "file", + }, + ], + "name": "users", + "type": "directory", + }, ] `); }); @@ -311,7 +443,7 @@ describe("'grpc-ts generate' cli command", () => { describe("when specifying a single file in ./protos/events.proto", () => { beforeAll(async () => { - await runCommand("./protos/events.proto", "./generated"); + await runCommand("../protos/events.proto", "./generated"); filesystemTree = getFilesystemTree(generatedDirectory); }); @@ -340,6 +472,14 @@ describe("'grpc-ts generate' cli command", () => { "name": "events_pb.js", "type": "file", }, + { + "name": "index.d.ts", + "type": "file", + }, + { + "name": "index.js", + "type": "file", + }, ], "name": "events", "type": "directory", @@ -352,7 +492,7 @@ describe("'grpc-ts generate' cli command", () => { describe("when specifying a single file in ./protos/accounts/infra/monitoring.proto", () => { beforeAll(async () => { await runCommand( - "./protos/accounts/infra/monitoring.proto", + "../protos/accounts/infra/monitoring.proto", "./generated" ); filesystemTree = getFilesystemTree(generatedDirectory); @@ -367,6 +507,14 @@ describe("'grpc-ts generate' cli command", () => { [ { "children": [ + { + "name": "index.d.ts", + "type": "file", + }, + { + "name": "index.js", + "type": "file", + }, { "name": "monitoring_grpc_pb.d.ts", "type": "file", @@ -395,7 +543,7 @@ describe("'grpc-ts generate' cli command", () => { describe("generating the output in a nested subdirectory", () => { beforeAll(async () => { await runCommand( - "./protos/accounts/infra/monitoring.proto", + "../protos/accounts/infra/monitoring.proto", "./generated/subdirectory/generated" ); filesystemTree = getFilesystemTree( @@ -412,6 +560,14 @@ describe("'grpc-ts generate' cli command", () => { [ { "children": [ + { + "name": "index.d.ts", + "type": "file", + }, + { + "name": "index.js", + "type": "file", + }, { "name": "monitoring_grpc_pb.d.ts", "type": "file", diff --git a/test/e2e/users.service.test.ts b/test/e2e/users.service.test.ts index 2c23b5b..0ac16d0 100644 --- a/test/e2e/users.service.test.ts +++ b/test/e2e/users.service.test.ts @@ -1,13 +1,8 @@ import * as grpc from "@grpc/grpc-js"; import { Empty } from "google-protobuf/google/protobuf/empty_pb"; import execa from "execa"; -import { createClient, GenericClient } from "../../src"; -import { - OnData, - ReadWriteStreamReturnType, - WriteStream, -} from "../../src/types"; -import { UsersClient } from "../generated/users/users_grpc_pb"; +import { OnData } from "@lewnelson/grpc-ts"; +import { UsersClient } from "../generated/users"; import { CreateUserRequest, EmitUserActionRequest, @@ -118,19 +113,16 @@ const startServers = (): [ describe("Users service", () => { let serverProcesses: { valid: ServerProcess; invalid: ServerProcess }; - let validClient: GenericClient; - let invalidClient: GenericClient; + let validClient: UsersClient; + let invalidClient: UsersClient; beforeAll(async () => { const [processes, serversReady] = startServers(); serverProcesses = processes; await serversReady; - validClient = createClient(UsersClient, `localhost:${VALID_SERVER_PORT}`); - invalidClient = createClient( - UsersClient, - `localhost:${INVALID_SERVER_PORT}` - ); + validClient = new UsersClient(`localhost:${VALID_SERVER_PORT}`); + invalidClient = new UsersClient(`localhost:${INVALID_SERVER_PORT}`); }); afterAll(() => { @@ -145,7 +137,7 @@ describe("Users service", () => { const request = new GetUserByIdRequest(); request.setId("user-1"); - const response = await validClient.unaryRequest("getUserById", request); + const response = await validClient.getUserById(request); expect(response.toObject()).toEqual({ user: { id: "user-1", @@ -158,7 +150,7 @@ describe("Users service", () => { it("should throw an error when the request fails", async () => { await expect( - invalidClient.unaryRequest("getUserById", new GetUserByIdRequest()) + invalidClient.getUserById(new GetUserByIdRequest()) ).rejects.toThrowGrpcException("User not found", grpc.status.NOT_FOUND); }); }); @@ -173,7 +165,7 @@ describe("Users service", () => { request.setUser(user); - const response = await validClient.unaryRequest("createUser", request); + const response = await validClient.createUser(request); expect(response.toObject()).toEqual({ user: { id: "user-2", @@ -186,7 +178,7 @@ describe("Users service", () => { it("should throw an error when the request fails", async () => { await expect( - invalidClient.unaryRequest("createUser", new CreateUserRequest()) + invalidClient.createUser(new CreateUserRequest()) ).rejects.toThrowGrpcException( "User with email already exists", grpc.status.ALREADY_EXISTS @@ -199,11 +191,7 @@ describe("Users service", () => { const request = new GetUsersByTeamIdRequest(); request.setTeamId("team-1"); - const response = await validClient.unaryRequest( - "getUsersByTeamId", - request - ); - + const response = await validClient.getUsersByTeamId(request); expect(response.toObject()).toEqual({ usersList: [ { @@ -224,10 +212,7 @@ describe("Users service", () => { it("should throw an error when the request fails", async () => { await expect( - invalidClient.unaryRequest( - "getUsersByTeamId", - new GetUsersByTeamIdRequest() - ) + invalidClient.getUsersByTeamId(new GetUsersByTeamIdRequest()) ).rejects.toThrowGrpcException( "Invalid team id", grpc.status.INVALID_ARGUMENT @@ -246,7 +231,7 @@ describe("Users service", () => { beforeAll(async () => { const request = new Empty(); - validClient.serverStreamRequest("userCreated", request, { + validClient.userCreated(request, { onData, }); @@ -338,7 +323,7 @@ describe("Users service", () => { beforeAll(async () => { const request = new Empty(); - invalidClient.serverStreamRequest("userCreated", request, { + invalidClient.userCreated(request, { onData, }); @@ -406,7 +391,7 @@ describe("Users service", () => { describe("client streaming", () => { describe("EmitUserAction", () => { - let stream: WriteStream; + let stream: ReturnType; let response: EmitUserActionResponse | undefined; let error: grpc.ServiceError | null; let onResponse: jest.Mock< @@ -421,10 +406,7 @@ describe("Users service", () => { describe("no requests", () => { beforeAll(async () => { onResponse = jest.fn(); - stream = await validClient.clientStreamRequest( - "emitUserAction", - onResponse - ); + stream = await validClient.emitUserAction(onResponse); const serverOutputPromise = waitForServerStdOut( serverProcesses.valid, @@ -454,11 +436,7 @@ describe("Users service", () => { describe("multiple requests", () => { beforeAll(async () => { onResponse = jest.fn(); - stream = await validClient.clientStreamRequest( - "emitUserAction", - onResponse - ); - + stream = await validClient.emitUserAction(onResponse); const requestOne = new EmitUserActionRequest(); requestOne.setUserId("user-1"); requestOne.setAction(UserAction.USER_ACTION_DOWNLOADED); @@ -513,11 +491,7 @@ describe("Users service", () => { describe("failed requests", () => { beforeAll(async () => { onResponse = jest.fn(); - stream = await invalidClient.clientStreamRequest( - "emitUserAction", - onResponse - ); - + stream = await invalidClient.emitUserAction(onResponse); const serverOutputPromise = waitForServerStdOut( serverProcesses.invalid, "EmitUserAction stream ended", @@ -547,7 +521,7 @@ describe("Users service", () => { describe("duplex streaming", () => { describe("UserUpdated", () => { - let stream: ReadWriteStreamReturnType; + let stream: ReturnType; let onData: jest.Mock< ReturnType>, Parameters> @@ -557,7 +531,7 @@ describe("Users service", () => { describe("when no requests are sent and the client closes the stream", () => { beforeAll(async () => { onData = jest.fn(); - stream = validClient.duplexStreamRequest("updateUser", { onData }); + stream = validClient.updateUser({ onData }); stream.end(); await waitForServerStdOut( serverProcesses.valid, @@ -575,7 +549,7 @@ describe("Users service", () => { describe("when the client sends multiple requests, then closes the stream", () => { beforeAll(async () => { onData = jest.fn(); - stream = validClient.duplexStreamRequest("updateUser", { onData }); + stream = validClient.updateUser({ onData }); const requestOne = new UpdateUserRequest(); requestOne.setUserId("user-1"); @@ -632,7 +606,7 @@ describe("Users service", () => { describe("when the server emits an error", () => { beforeAll(async () => { onData = jest.fn(); - stream = invalidClient.duplexStreamRequest("updateUser", { onData }); + stream = invalidClient.updateUser({ onData }); const serverStdOutPromise = waitForServerStdOut( serverProcesses.invalid, "UpdateUser stream ended", diff --git a/test/cli/protos/accounts/events.proto b/test/protos/accounts/events.proto similarity index 100% rename from test/cli/protos/accounts/events.proto rename to test/protos/accounts/events.proto diff --git a/test/cli/protos/accounts/infra/monitoring.proto b/test/protos/accounts/infra/monitoring.proto similarity index 100% rename from test/cli/protos/accounts/infra/monitoring.proto rename to test/protos/accounts/infra/monitoring.proto diff --git a/test/cli/protos/accounts/users.proto b/test/protos/accounts/users.proto similarity index 100% rename from test/cli/protos/accounts/users.proto rename to test/protos/accounts/users.proto diff --git a/test/cli/protos/events.proto b/test/protos/events.proto similarity index 100% rename from test/cli/protos/events.proto rename to test/protos/events.proto diff --git a/test/cli/protos/logger.proto b/test/protos/logger.proto similarity index 100% rename from test/cli/protos/logger.proto rename to test/protos/logger.proto diff --git a/test/cli/protos/people.proto b/test/protos/people.proto similarity index 100% rename from test/cli/protos/people.proto rename to test/protos/people.proto diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 0000000..e5c1c56 --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "include": ["**/*.ts"], + "compilerOptions": { + "paths": { + "@lewnelson/grpc-ts": ["../src/index.ts"] + } + } +}