From d8bda80ab1250b1bf90544bb93416b4d6286b29e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C6=B0=C6=A1ng=20T=E1=BA=A5n=20Hu=E1=BB=B3nh=20Phong?= Date: Sun, 14 Apr 2024 15:48:29 +0700 Subject: [PATCH] [cli]: rework logic to support nested packages --- .../src/protobufTypings/example.interface.ts | 3 + .../src/protobufTypings/example2.interface.ts | 12 +- .../src/protobufTypings/example3.interface.ts | 3 + .../nested_example.interface.ts | 8 + .../protobufTypings/example.interface.ts | 3 + .../protobufTypings/example2.interface.ts | 12 +- .../protobufTypings/example3.interface.ts | 3 + .../nested_example.interface.ts | 8 + examples/proto/example.proto | 3 + examples/proto/example2.proto | 7 +- examples/proto/example3.proto | 3 + examples/proto/nested_example.proto | 10 + packages/cli/package.json | 2 +- packages/cli/src/interface.ts | 13 +- packages/cli/src/run.ts | 117 +---------- .../tasks/createContent/createExportEnums.ts | 47 +++++ .../createContent/createExportMessages.ts | 143 +++++++++++++ .../createContent/createExportPackageName.ts | 7 + .../createContent/createExportServices.ts | 93 +++++++++ .../src/tasks/createContent/createImports.ts | 79 ++++++++ packages/cli/src/tasks/createContent/index.ts | 112 +++++++++++ .../cli/src/tasks/createContent/interface.ts | 17 ++ packages/cli/src/tasks/createContent/utils.ts | 21 ++ packages/cli/src/tasks/loadData.ts | 88 ++++++++ packages/cli/src/tasks/topologicalGroup.ts | 65 ++++++ packages/cli/src/utils/arrayUtils.ts | 11 + packages/cli/src/utils/contentHelper.ts | 189 ------------------ packages/cli/src/utils/debugger.ts | 12 ++ packages/cli/src/utils/index.ts | 3 - packages/cli/src/utils/objectUtils.ts | 17 ++ packages/cli/src/utils/parsers.ts | 4 +- .../utils/{formatter.ts => stringUtils.ts} | 56 ++---- 32 files changed, 818 insertions(+), 353 deletions(-) create mode 100644 examples/fastify/src/protobufTypings/nested_example.interface.ts create mode 100644 examples/nestjs/protobufTypings/nested_example.interface.ts create mode 100644 examples/proto/nested_example.proto create mode 100644 packages/cli/src/tasks/createContent/createExportEnums.ts create mode 100644 packages/cli/src/tasks/createContent/createExportMessages.ts create mode 100644 packages/cli/src/tasks/createContent/createExportPackageName.ts create mode 100644 packages/cli/src/tasks/createContent/createExportServices.ts create mode 100644 packages/cli/src/tasks/createContent/createImports.ts create mode 100644 packages/cli/src/tasks/createContent/index.ts create mode 100644 packages/cli/src/tasks/createContent/interface.ts create mode 100644 packages/cli/src/tasks/createContent/utils.ts create mode 100644 packages/cli/src/tasks/loadData.ts create mode 100644 packages/cli/src/tasks/topologicalGroup.ts create mode 100644 packages/cli/src/utils/arrayUtils.ts delete mode 100644 packages/cli/src/utils/contentHelper.ts create mode 100644 packages/cli/src/utils/debugger.ts delete mode 100644 packages/cli/src/utils/index.ts create mode 100644 packages/cli/src/utils/objectUtils.ts rename packages/cli/src/utils/{formatter.ts => stringUtils.ts} (50%) diff --git a/examples/fastify/src/protobufTypings/example.interface.ts b/examples/fastify/src/protobufTypings/example.interface.ts index 1b9f9ba..f788be8 100644 --- a/examples/fastify/src/protobufTypings/example.interface.ts +++ b/examples/fastify/src/protobufTypings/example.interface.ts @@ -1,5 +1,7 @@ import type { Metadata, GrpcTimestamp, ServiceClient } from '@grpc.ts/core'; +import type { INested as INestedV1Nested } from './nested_example.interface'; + export const PACKAGE_NAME = 'example.v1'; export const messageEnum = { @@ -17,6 +19,7 @@ export type TMessageEnum = 'unknown' | 'info'; export interface IMessage { message: string; createdAt: GrpcTimestamp; + nested: INestedV1Nested; } export interface ISendMessageRequest { diff --git a/examples/fastify/src/protobufTypings/example2.interface.ts b/examples/fastify/src/protobufTypings/example2.interface.ts index bd04b26..3142669 100644 --- a/examples/fastify/src/protobufTypings/example2.interface.ts +++ b/examples/fastify/src/protobufTypings/example2.interface.ts @@ -1,10 +1,18 @@ import type { Metadata, GrpcTimestamp, ServiceClient } from '@grpc.ts/core'; +import type { + IMessage as IExampleV1Message, + ISendMessageRequest as IExampleV1SendMessageRequest, +} from './example.interface'; +import type { INested as INestedV1Nested } from './nested_example.interface'; + export const PACKAGE_NAME = 'example2.v1'; export interface IMessage { message: string; createdAt: GrpcTimestamp; + exampleMessage: IExampleV1Message; + nested: INestedV1Nested; } export interface ISendMessageRequest { @@ -20,11 +28,11 @@ export const EXAMPLE_SERVICE = 'ExampleService'; export interface IExampleService extends ServiceClient { SendMessage( - params: ISendMessageRequest, + params: IExampleV1SendMessageRequest, metadata?: Metadata, ): Promise; sendMessage( - params: ISendMessageRequest, + params: IExampleV1SendMessageRequest, metadata?: Metadata, ): Promise; } diff --git a/examples/fastify/src/protobufTypings/example3.interface.ts b/examples/fastify/src/protobufTypings/example3.interface.ts index 672d7f5..a4b49c8 100644 --- a/examples/fastify/src/protobufTypings/example3.interface.ts +++ b/examples/fastify/src/protobufTypings/example3.interface.ts @@ -1,9 +1,12 @@ /* eslint-disable @typescript-eslint/ban-types */ import type { Metadata, GrpcTimestamp, ServiceClient } from '@grpc.ts/core'; +import type { INested as INestedV1Nested } from './nested_example.interface'; + export interface IMessage { message: string; createdAt: GrpcTimestamp; + nested: INestedV1Nested; } export interface ISendMessageRequest { diff --git a/examples/fastify/src/protobufTypings/nested_example.interface.ts b/examples/fastify/src/protobufTypings/nested_example.interface.ts new file mode 100644 index 0000000..d4cd234 --- /dev/null +++ b/examples/fastify/src/protobufTypings/nested_example.interface.ts @@ -0,0 +1,8 @@ +import type { GrpcTimestamp } from '@grpc.ts/core'; + +export const PACKAGE_NAME = 'nested.v1'; + +export interface INested { + nested: string; + createdAt: GrpcTimestamp; +} diff --git a/examples/nestjs/protobufTypings/example.interface.ts b/examples/nestjs/protobufTypings/example.interface.ts index 1b9f9ba..f788be8 100644 --- a/examples/nestjs/protobufTypings/example.interface.ts +++ b/examples/nestjs/protobufTypings/example.interface.ts @@ -1,5 +1,7 @@ import type { Metadata, GrpcTimestamp, ServiceClient } from '@grpc.ts/core'; +import type { INested as INestedV1Nested } from './nested_example.interface'; + export const PACKAGE_NAME = 'example.v1'; export const messageEnum = { @@ -17,6 +19,7 @@ export type TMessageEnum = 'unknown' | 'info'; export interface IMessage { message: string; createdAt: GrpcTimestamp; + nested: INestedV1Nested; } export interface ISendMessageRequest { diff --git a/examples/nestjs/protobufTypings/example2.interface.ts b/examples/nestjs/protobufTypings/example2.interface.ts index bd04b26..3142669 100644 --- a/examples/nestjs/protobufTypings/example2.interface.ts +++ b/examples/nestjs/protobufTypings/example2.interface.ts @@ -1,10 +1,18 @@ import type { Metadata, GrpcTimestamp, ServiceClient } from '@grpc.ts/core'; +import type { + IMessage as IExampleV1Message, + ISendMessageRequest as IExampleV1SendMessageRequest, +} from './example.interface'; +import type { INested as INestedV1Nested } from './nested_example.interface'; + export const PACKAGE_NAME = 'example2.v1'; export interface IMessage { message: string; createdAt: GrpcTimestamp; + exampleMessage: IExampleV1Message; + nested: INestedV1Nested; } export interface ISendMessageRequest { @@ -20,11 +28,11 @@ export const EXAMPLE_SERVICE = 'ExampleService'; export interface IExampleService extends ServiceClient { SendMessage( - params: ISendMessageRequest, + params: IExampleV1SendMessageRequest, metadata?: Metadata, ): Promise; sendMessage( - params: ISendMessageRequest, + params: IExampleV1SendMessageRequest, metadata?: Metadata, ): Promise; } diff --git a/examples/nestjs/protobufTypings/example3.interface.ts b/examples/nestjs/protobufTypings/example3.interface.ts index 672d7f5..a4b49c8 100644 --- a/examples/nestjs/protobufTypings/example3.interface.ts +++ b/examples/nestjs/protobufTypings/example3.interface.ts @@ -1,9 +1,12 @@ /* eslint-disable @typescript-eslint/ban-types */ import type { Metadata, GrpcTimestamp, ServiceClient } from '@grpc.ts/core'; +import type { INested as INestedV1Nested } from './nested_example.interface'; + export interface IMessage { message: string; createdAt: GrpcTimestamp; + nested: INestedV1Nested; } export interface ISendMessageRequest { diff --git a/examples/nestjs/protobufTypings/nested_example.interface.ts b/examples/nestjs/protobufTypings/nested_example.interface.ts new file mode 100644 index 0000000..d4cd234 --- /dev/null +++ b/examples/nestjs/protobufTypings/nested_example.interface.ts @@ -0,0 +1,8 @@ +import type { GrpcTimestamp } from '@grpc.ts/core'; + +export const PACKAGE_NAME = 'nested.v1'; + +export interface INested { + nested: string; + createdAt: GrpcTimestamp; +} diff --git a/examples/proto/example.proto b/examples/proto/example.proto index 1b49215..071421a 100644 --- a/examples/proto/example.proto +++ b/examples/proto/example.proto @@ -2,6 +2,8 @@ syntax = "proto3"; import "google/protobuf/timestamp.proto"; +import "nested_example.proto"; + package example.v1; enum MessageEnum { @@ -12,6 +14,7 @@ enum MessageEnum { message Message { string message = 1; google.protobuf.Timestamp created_at = 2; + nested.v1.Nested nested = 3; } message SendMessageRequest { diff --git a/examples/proto/example2.proto b/examples/proto/example2.proto index 5810540..0c2c110 100644 --- a/examples/proto/example2.proto +++ b/examples/proto/example2.proto @@ -2,11 +2,16 @@ syntax = "proto3"; import "google/protobuf/timestamp.proto"; +import "example.proto"; +import "nested_example.proto"; + package example2.v1; message Message { string message = 1; google.protobuf.Timestamp created_at = 2; + example.v1.Message example_message = 3; + nested.v1.Nested nested = 4; } message SendMessageRequest { @@ -17,5 +22,5 @@ message SendMessageRequest { message GetMessageResponse { Message message = 1; } service ExampleService { - rpc SendMessage(SendMessageRequest) returns (GetMessageResponse); + rpc SendMessage(example.v1.SendMessageRequest) returns (GetMessageResponse); } diff --git a/examples/proto/example3.proto b/examples/proto/example3.proto index 1b19dc3..b42a5c8 100644 --- a/examples/proto/example3.proto +++ b/examples/proto/example3.proto @@ -3,9 +3,12 @@ syntax = "proto3"; import "google/protobuf/empty.proto"; import "google/protobuf/timestamp.proto"; +import "nested_example.proto"; + message Message { string message = 1; google.protobuf.Timestamp created_at = 2; + nested.v1.Nested nested = 3; } message SendMessageRequest { diff --git a/examples/proto/nested_example.proto b/examples/proto/nested_example.proto new file mode 100644 index 0000000..ff790d6 --- /dev/null +++ b/examples/proto/nested_example.proto @@ -0,0 +1,10 @@ +syntax = "proto3"; + +import "google/protobuf/timestamp.proto"; + +package nested.v1; + +message Nested { + string nested = 1; + google.protobuf.Timestamp created_at = 2; +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 63b9f96..9916214 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@grpc.ts/cli", - "version": "1.0.3", + "version": "1.1.0", "license": "MIT", "directories": { "lib": "lib" diff --git a/packages/cli/src/interface.ts b/packages/cli/src/interface.ts index 855d252..e08de31 100644 --- a/packages/cli/src/interface.ts +++ b/packages/cli/src/interface.ts @@ -6,10 +6,6 @@ export interface IConfigProps { paths?: string | string[]; } -export interface IOptionsProps { - external?: string[]; -} - export interface IMessageProps { name: string; type: string; @@ -39,6 +35,11 @@ export interface INamespaceDataProps { export type TParseNamespaceReturn = [string, INamespaceDataProps]; -export interface ICachedEnumsProps { - [key: string]: boolean; +export interface IProtoDataProps { + filePath: string; + packageName: string; + noDependency: boolean; + data: INamespaceDataProps; + ownedMessages: Record; + dependencies: Record; } diff --git a/packages/cli/src/run.ts b/packages/cli/src/run.ts index ed2e542..0415876 100644 --- a/packages/cli/src/run.ts +++ b/packages/cli/src/run.ts @@ -1,114 +1,15 @@ -import { glob } from 'glob'; -import { resolve } from 'path'; -import { format } from 'prettier'; -import { load } from 'protobufjs'; -import { cwd } from 'node:process'; -import { writeFile, mkdir } from 'node:fs/promises'; +import { loadData } from './tasks/loadData'; +import { loadConfig } from './utils/configuration'; +import { createContent } from './tasks/createContent'; +import { topologicalGroup } from './tasks/topologicalGroup'; -import { parse, wrapArray, loadConfig, combine } from './utils'; -import { - createImportType, - createExportEnums, - createExportMessages, - createExportServices, - createExportPackageName, -} from './utils/contentHelper'; +async function generate(): Promise { + const config = await loadConfig(); + const protoData = await loadData(config); -async function generate() { - const { external, output, paths } = await loadConfig(); - const filePaths = await glob( - wrapArray(paths).map((path) => resolve(cwd(), path)), - ); + const groupedProtoData = topologicalGroup(protoData); - Promise.all( - filePaths.map(async (filePath) => { - const root = await load(filePath); - - const data = root.toJSON().nested; - - if (!data) { - console.warn('No data from proto file'); - - return; - } - - const result = parse(data, { - external, - }); - - let fileContent = createExportPackageName(result[0][0]); - let hasMetadata = false; - let hasGrpcTimestamp = false; - let hasService = false; - let hasBanType = false; - - result.forEach(([_, packageData]) => { - const [enumsContent, cachedEnums] = createExportEnums( - packageData.enums, - ); - - const [messagesContent, containsGrpcTimestamp] = createExportMessages( - packageData.messages, - cachedEnums, - ); - - if (containsGrpcTimestamp) { - hasGrpcTimestamp = containsGrpcTimestamp; - } - - if (packageData.services.length) { - hasService = true; - hasMetadata = true; - } - - const [servicesContent, containsBanType] = createExportServices( - packageData.services, - ); - - if (containsBanType) { - hasBanType = containsBanType; - } - - fileContent = combine( - { - joinWith: '\n', - }, - fileContent, - enumsContent, - messagesContent, - servicesContent, - ); - }); - - const fileName = filePath.slice( - filePath.lastIndexOf('/') + 1, - filePath.length - '.proto'.length, - ); - - fileContent = await createImportType(fileContent, { - hasService, - hasBanType, - hasMetadata, - hasGrpcTimestamp, - }); - - await mkdir(output, { recursive: true }); - - writeFile( - `${output}/${fileName}.interface.ts`, - await format(fileContent, { - singleQuote: true, - trailingComma: 'all', - jsxSingleQuote: true, - parser: 'typescript', - arrowParens: 'always', - }), - { - encoding: 'utf8', - }, - ); - }), - ); + createContent(groupedProtoData, config); } generate(); diff --git a/packages/cli/src/tasks/createContent/createExportEnums.ts b/packages/cli/src/tasks/createContent/createExportEnums.ts new file mode 100644 index 0000000..354591a --- /dev/null +++ b/packages/cli/src/tasks/createContent/createExportEnums.ts @@ -0,0 +1,47 @@ +import { camelize, lowerFirstChar } from '../../utils/stringUtils'; + +import type { ICachedEnumsProps } from './interface'; +import type { TNamespaceEnum } from '../../interface'; + +export function createExportEnums( + enums: TNamespaceEnum[], +): [string, ICachedEnumsProps] { + let content = ''; + const cachedEnums: ICachedEnumsProps = {}; + + enums.forEach((enu) => { + const [enumName, values] = enu; + cachedEnums[enumName] = true; + + const valuesAsStrings = values.reduce<[string, string, string]>( + (result, vals) => { + Object.entries(vals).forEach(([k, v]) => { + const camelizedKey = camelize(k); + + result[0] += `${camelizedKey}: ${v},\n`; + result[1] += `| '${camelizedKey}'\n`; + result[2] += `${v}: '${camelizedKey}',\n`; + }); + + return result; + }, + ['', '', ''], + ); + + const enumExportName = lowerFirstChar(enumName); + + content += ` + export const ${enumExportName} = { + ${valuesAsStrings[0]} + }; + + export const ${enumExportName}Mapper = { + ${valuesAsStrings[2]} + }; + + export type T${enumName} = ${valuesAsStrings[1]}; + `; + }); + + return [content, cachedEnums]; +} diff --git a/packages/cli/src/tasks/createContent/createExportMessages.ts b/packages/cli/src/tasks/createContent/createExportMessages.ts new file mode 100644 index 0000000..a71e6ca --- /dev/null +++ b/packages/cli/src/tasks/createContent/createExportMessages.ts @@ -0,0 +1,143 @@ +import { makeMessageInterface } from './utils'; +import { capitalize, combine } from '../../utils/stringUtils'; + +import type { ICachedEnumsProps, ICachedTypesProps } from './interface'; +import type { IProtoDataProps, TNamespaceMessage } from '../../interface'; + +interface ICreateExportMessagesParams { + messages: TNamespaceMessage[]; + cachedEnums: ICachedEnumsProps; + cachedTypes: ICachedTypesProps; + ownedMessages: IProtoDataProps['ownedMessages']; +} + +export function createExportMessages({ + messages, + cachedTypes, + cachedEnums, + ownedMessages, +}: ICreateExportMessagesParams): [string, boolean, Record] { + const dependentTypes: Record = {}; + let hasGrpcTimestamp = false; + + const fileContent = messages.reduce((content, message) => { + const [messageName, fields] = message; + + const fieldsAsString = fields.reduce( + (result, { name, optional, type, rule }) => { + const tsKey = [name, optional ? '?' : ''].join(''); + + const [tsPrimitiveType, packageName, packageMessage] = lookupType( + type, + cachedEnums, + cachedTypes, + ownedMessages, + ); + + if (packageName && packageMessage) { + dependentTypes[packageName] ||= []; + dependentTypes[packageName].push(packageMessage); + } + + const tsType = [tsPrimitiveType, rule === 'repeated' ? '[]' : ''].join( + '', + ); + + result += combine( + { + joinWith: ': ', + }, + tsKey, + combine( + { + joinWith: '', + }, + tsType, + ';', + ), + ); + + if (type === 'google.protobuf.Timestamp') { + hasGrpcTimestamp = true; + } + + return result; + }, + '', + ); + + content += ` + export interface I${capitalize(messageName)} { + ${fieldsAsString} + } + `; + + return content; + }, ''); + + return [fileContent, hasGrpcTimestamp, dependentTypes]; +} + +function lookupType( + type: string, + cachedEnums: ICachedEnumsProps, + cachedTypes: ICachedTypesProps, + ownedMessages: IProtoDataProps['ownedMessages'], +): [string] | [string, string, string] { + if (cachedEnums[type]) { + return ['number']; + } + + if (type === 'google.protobuf.Timestamp') { + return ['GrpcTimestamp']; + } + + if (ownedMessages[type]) { + return [convertTypeScriptType(type)]; + } + + const cachedType = cachedTypes[type]; + + if (cachedType) { + return [ + makeMessageInterface({ + packageName: cachedType.packageName, + messageName: cachedType.messageName, + }), + cachedType.packageName, + cachedType.messageName, + ]; + } + + return [convertTypeScriptType(type)]; +} + +function convertTypeScriptType(type: string): string { + switch (type) { + case 'double': + case 'float': + case 'int32': + case 'int64': + case 'uint32': + case 'uint64': + case 'sint32': + case 'sint64': + case 'fixed32': + case 'fixed64': + case 'sfixed32': + case 'sfixed64': + return 'number'; + case 'bool': + return 'boolean'; + case 'bytes': + return 'number[]'; + case 'string': + return 'string'; + default: + if (type.includes('.')) { + return 'unknown'; + } + + return `I${type}`; + } +} diff --git a/packages/cli/src/tasks/createContent/createExportPackageName.ts b/packages/cli/src/tasks/createContent/createExportPackageName.ts new file mode 100644 index 0000000..146ab41 --- /dev/null +++ b/packages/cli/src/tasks/createContent/createExportPackageName.ts @@ -0,0 +1,7 @@ +export function createExportPackageName(packageName: string): string { + if (packageName) { + return `export const PACKAGE_NAME = '${packageName}'\n\n`; + } + + return ''; +} diff --git a/packages/cli/src/tasks/createContent/createExportServices.ts b/packages/cli/src/tasks/createContent/createExportServices.ts new file mode 100644 index 0000000..eb081f6 --- /dev/null +++ b/packages/cli/src/tasks/createContent/createExportServices.ts @@ -0,0 +1,93 @@ +import { makeMessageInterface } from './utils'; +import { capitalize, toSnakeCase } from '../../utils/stringUtils'; + +import type { ICachedTypesProps, TDependentTypes } from './interface'; +import type { IProtoDataProps, TNamespaceService } from '../../interface'; + +interface ICreateExportServicesParams { + services: TNamespaceService[]; + cachedTypes: ICachedTypesProps; + ownedMessages: IProtoDataProps['ownedMessages']; +} + +export function createExportServices({ + services, + cachedTypes, + ownedMessages, +}: ICreateExportServicesParams): [string, boolean, TDependentTypes] { + const dependentTypes: TDependentTypes = {}; + let hasBanType = false; + + const fileContent = services.reduce((content, service) => { + const [serviceName, methods] = service; + + const methodsAsString = methods.reduce( + (result, { name, requestType, responseType }) => { + const [tsType, packageName, packageMessage] = lookupType( + requestType, + cachedTypes, + ownedMessages, + ); + const paramsAndResponse = `(params: ${tsType}, metadata?: Metadata): Promise;`; + + if (tsType === '{}') { + hasBanType = true; + } + + if (packageName && packageMessage) { + dependentTypes[packageName] ||= []; + dependentTypes[packageName].push(packageMessage); + } + + result += `${name}${paramsAndResponse}\n`; + result += `${ + name.charAt(0).toLowerCase() + name.slice(1) + }${paramsAndResponse}\n`; + + return result; + }, + '', + ); + + content += ` + export const ${toSnakeCase(serviceName).toUpperCase()} = '${serviceName}'; + + export interface I${serviceName} extends ServiceClient { + ${methodsAsString} + } + `; + + return content; + }, ''); + + return [fileContent, hasBanType, dependentTypes]; +} + +function lookupType( + requestType: string, + cachedTypes: ICachedTypesProps, + ownedMessages: IProtoDataProps['ownedMessages'], +): [string] | [string, string, string] { + if (ownedMessages[requestType]) { + return [`I${capitalize(requestType)}`]; + } + + if (requestType === 'google.protobuf.Empty') { + return ['{}']; + } + + if (cachedTypes[requestType]) { + const { messageName, packageName } = cachedTypes[requestType]; + + return [ + makeMessageInterface({ + packageName, + messageName, + }), + packageName, + messageName, + ]; + } + + return ['unknown']; +} diff --git a/packages/cli/src/tasks/createContent/createImports.ts b/packages/cli/src/tasks/createContent/createImports.ts new file mode 100644 index 0000000..2910b33 --- /dev/null +++ b/packages/cli/src/tasks/createContent/createImports.ts @@ -0,0 +1,79 @@ +import { makeMessageInterface } from './utils'; +import { combine } from '../../utils/stringUtils'; +import { mergeObj } from '../../utils/objectUtils'; + +import type { ICachedPackageOutputsProps, TDependentTypes } from './interface'; + +interface ICreateImportsParams { + hasBanType: boolean; + hasService: boolean; + hasGrpcTimestamp: boolean; + messageDependentTypes: TDependentTypes; + serviceDependentTypes: TDependentTypes; + cachedPackageOutputs: ICachedPackageOutputsProps; +} + +export function createImports({ + hasBanType, + hasService, + hasGrpcTimestamp, + cachedPackageOutputs, + messageDependentTypes, + serviceDependentTypes, +}: ICreateImportsParams) { + const corePackage = '@grpc.ts/core'; + const dependentTypes = mergeObj(messageDependentTypes, serviceDependentTypes); + + return combine( + { + joinWith: '\n', + }, + hasBanType ? '/* eslint-disable @typescript-eslint/ban-types */' : '', + combine( + 'import type {', + combine( + { + joinWith: ', ', + }, + hasService ? 'Metadata' : '', + hasGrpcTimestamp ? 'GrpcTimestamp' : '', + hasService ? 'ServiceClient' : '', + ), + '} from', + `'${corePackage}'`, + ), + '\n', + createImportDependentTypes(dependentTypes, cachedPackageOutputs), + ); +} + +function createImportDependentTypes( + dependentTypes: TDependentTypes, + cachedPackageOutputs: ICachedPackageOutputsProps, +): string { + return Object.entries(dependentTypes).reduce( + (content, [packageName, types]) => { + const output = cachedPackageOutputs[packageName].replace('.ts', ''); + + content += combine( + 'import type {', + types.reduce((result, type) => { + const alias = makeMessageInterface({ + packageName, + messageName: type, + }); + + result += combine(`I${type}`, 'as', alias); + result += ', '; + + return result; + }, ''), + '} from', + `'./${output}';`, + ); + + return content; + }, + '', + ); +} diff --git a/packages/cli/src/tasks/createContent/index.ts b/packages/cli/src/tasks/createContent/index.ts new file mode 100644 index 0000000..bf5f41a --- /dev/null +++ b/packages/cli/src/tasks/createContent/index.ts @@ -0,0 +1,112 @@ +import { format } from 'prettier'; +import { mkdir, writeFile } from 'node:fs/promises'; + +import { createImports } from './createImports'; +import { combine } from '../../utils/stringUtils'; +import { createExportEnums } from './createExportEnums'; +import { createExportMessages } from './createExportMessages'; +import { createExportServices } from './createExportServices'; +import { createExportPackageName } from './createExportPackageName'; + +import type { IConfigProps, IProtoDataProps } from '../../interface'; +import type { + ICachedTypesProps, + ICachedPackageOutputsProps, +} from './interface'; + +export async function createContent( + groupedData: IProtoDataProps[][], + config: Required, +): Promise { + const { output } = config; + const cachedTypes: ICachedTypesProps = {}; + const cachedPackageOutputs: ICachedPackageOutputsProps = {}; + + for (const groupedDatumn of groupedData) { + await Promise.all( + groupedDatumn.map(async (protoData) => { + const { data, filePath, packageName, ownedMessages } = protoData; + const { enums, messages, services } = data; + + const packageNameContent = createExportPackageName(packageName); + + const [enumsContent, cachedEnums] = createExportEnums(enums); + + const [messagesContent, hasGrpcTimestamp, messageDependentTypes] = + createExportMessages({ + messages, + cachedEnums, + cachedTypes, + ownedMessages, + }); + + const [servicesContent, hasBanType, serviceDependentTypes] = + createExportServices({ + services, + cachedTypes, + ownedMessages, + }); + + const importsContent = createImports({ + hasBanType, + hasGrpcTimestamp, + cachedPackageOutputs, + messageDependentTypes, + serviceDependentTypes, + hasService: services.length > 0, + }); + + const fileContent = combine( + { joinWith: '\n' }, + importsContent, + '\n', + packageNameContent, + enumsContent, + messagesContent, + servicesContent, + ); + + Object.keys(ownedMessages).forEach((ownedMessage) => { + const key = combine({ joinWith: '.' }, packageName, ownedMessage); + + cachedTypes[key] = { + filePath, + packageName, + messageName: ownedMessage, + }; + }); + + const fileName = combine( + { + joinWith: '.', + }, + filePath.slice( + filePath.lastIndexOf('/') + 1, + filePath.length - '.proto'.length, + ), + 'interface.ts', + ); + + cachedPackageOutputs[packageName] = fileName; + + await mkdir(output, { recursive: true }); + + writeFile( + `${output}/${fileName}`, + await format(fileContent, { + singleQuote: true, + trailingComma: 'all', + jsxSingleQuote: true, + parser: 'typescript', + arrowParens: 'always', + }), + { + encoding: 'utf8', + }, + ).then(() => { + console.log(`Created ${output}/${fileName}`); + }); + }), + ); + } +} diff --git a/packages/cli/src/tasks/createContent/interface.ts b/packages/cli/src/tasks/createContent/interface.ts new file mode 100644 index 0000000..39ac97e --- /dev/null +++ b/packages/cli/src/tasks/createContent/interface.ts @@ -0,0 +1,17 @@ +export type TDependentTypes = Record; + +export interface ICachedPackageOutputsProps { + [key: string]: string; +} + +export interface ICachedEnumsProps { + [key: string]: boolean; +} + +export interface ICachedTypesProps { + [key: string]: { + filePath: string; + packageName: string; + messageName: string; + }; +} diff --git a/packages/cli/src/tasks/createContent/utils.ts b/packages/cli/src/tasks/createContent/utils.ts new file mode 100644 index 0000000..ebe4bf6 --- /dev/null +++ b/packages/cli/src/tasks/createContent/utils.ts @@ -0,0 +1,21 @@ +import { capitalize, combine } from '../../utils/stringUtils'; + +interface IMakeMessageInterfaceParams { + packageName: string; + messageName: string; +} + +export function makeMessageInterface({ + packageName, + messageName, +}: IMakeMessageInterfaceParams): string { + return combine( + { joinWith: '' }, + 'I', + packageName + .split('.') + .map((e) => capitalize(e)) + .join(''), + messageName, + ); +} diff --git a/packages/cli/src/tasks/loadData.ts b/packages/cli/src/tasks/loadData.ts new file mode 100644 index 0000000..70fbe44 --- /dev/null +++ b/packages/cli/src/tasks/loadData.ts @@ -0,0 +1,88 @@ +import { glob } from 'glob'; +import { resolve } from 'path'; +import { load } from 'protobufjs'; +import { cwd } from 'node:process'; + +import { parse } from '../utils/parsers'; +import { group, wrapArray } from '../utils/arrayUtils'; + +import type { + IConfigProps, + IProtoDataProps, + TParseNamespaceReturn, +} from '../interface'; + +export async function loadData( + config: Required, +): Promise> { + const { paths } = config; + + const filePaths = await glob( + wrapArray(paths).map((path) => resolve(cwd(), path)), + ); + + return Promise.all( + filePaths.map(async (filePath) => { + const root = await load(filePath); + + const data = root.toJSON().nested; + + if (!data) { + console.warn('No data from proto file'); + + return null; + } + + const parsedData = parse(data, config); + + return { + filePath, + ...parseProtoData(parsedData), + }; + }), + ); +} + +type TParseProtoDataReturn = Omit; + +function parseProtoData(data: TParseNamespaceReturn[]): TParseProtoDataReturn { + const result: TParseProtoDataReturn = { + packageName: '', + dependencies: {}, + ownedMessages: {}, + noDependency: true, + data: { + enums: [], + messages: [], + services: [], + }, + }; + + let index = 0; + const len = data.length - 1; + const last = data[data.length - 1][1]; + const firstElement = data[0]; + + if (last.enums.length || last.messages.length || last.services.length) { + Object.assign(result, { + data: last, + ownedMessages: group(last.messages, (message) => message[0]), + }); + } else { + index += 1; + + Object.assign(result, { + data: firstElement[1], + packageName: firstElement[0], + ownedMessages: group(firstElement[1].messages, (message) => message[0]), + }); + } + + for (index; index < len; index++) { + const element = data[index]; + result.noDependency = false; + result.dependencies[element[0]] = element[1]; + } + + return result; +} diff --git a/packages/cli/src/tasks/topologicalGroup.ts b/packages/cli/src/tasks/topologicalGroup.ts new file mode 100644 index 0000000..4ec3ccb --- /dev/null +++ b/packages/cli/src/tasks/topologicalGroup.ts @@ -0,0 +1,65 @@ +import type { IProtoDataProps } from '../interface'; + +export function topologicalGroup( + protoData: Array, +): IProtoDataProps[][] { + const visited: Record = {}; + const result: IProtoDataProps[][] = []; + + while (protoData.length) { + const removedIndexes = []; + + for (let i = 0, n = protoData.length; i < n; i++) { + const protoDatumn = protoData[i]; + + if (!protoDatumn) { + removedIndexes.push(i); + continue; + } + + const { packageName, filePath, dependencies } = protoDatumn; + const dependencyList = Object.keys(dependencies); + + if (!dependencyList.length) { + result[0] ||= []; + result[0].push(protoDatumn); + removedIndexes.push(i); + + visited[packageName || filePath] = { + level: 0, + visited: true, + }; + + continue; + } + + let level = 0; + let visitedAllDependencies = true; + + dependencyList.forEach((dependency) => { + if (visited[dependency]) { + level = Math.max(level, visited[dependency].level); + } else { + visitedAllDependencies = false; + } + }); + + if (visitedAllDependencies) { + result[level + 1] ||= []; + result[level + 1].push(protoDatumn); + removedIndexes.push(i); + + visited[packageName || filePath] = { + visited: true, + level: level + 1, + }; + } + } + + removedIndexes.reverse().forEach((index) => { + protoData.splice(index, 1); + }); + } + + return result; +} diff --git a/packages/cli/src/utils/arrayUtils.ts b/packages/cli/src/utils/arrayUtils.ts new file mode 100644 index 0000000..b903c64 --- /dev/null +++ b/packages/cli/src/utils/arrayUtils.ts @@ -0,0 +1,11 @@ +export function wrapArray(data: T): T extends any[] ? T : T[] { + return (Array.isArray(data) ? data : [data]) as T extends any[] ? T : T[]; +} + +export function group(data: any[], callback: (datumn: any) => any): T { + return data.reduce((result, datumn) => { + result[callback(datumn)] = datumn; + + return result; + }, {}); +} diff --git a/packages/cli/src/utils/contentHelper.ts b/packages/cli/src/utils/contentHelper.ts deleted file mode 100644 index 0bbf953..0000000 --- a/packages/cli/src/utils/contentHelper.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { - combine, - camelize, - toSnakeCase, - lowerFirstChar, - convertTypeScriptType, - convertTypeScriptRequestType, -} from './formatter'; - -import type { - TNamespaceEnum, - TNamespaceMessage, - TNamespaceService, - ICachedEnumsProps, -} from '../interface'; - -interface ICreateImportTypeOptionsProps { - hasBanType: boolean; - hasService: boolean; - hasMetadata: boolean; - hasGrpcTimestamp: boolean; -} - -export async function createImportType( - content: string, - { - hasBanType, - hasService, - hasMetadata, - hasGrpcTimestamp, - }: ICreateImportTypeOptionsProps, -): Promise { - const packageName = '@grpc.ts/core'; - - return combine( - { - joinWith: '\n', - }, - hasBanType ? '/* eslint-disable @typescript-eslint/ban-types */' : '', - combine( - { - joinWith: ' ', - }, - 'import type {', - combine( - { - joinWith: ', ', - }, - hasMetadata ? 'Metadata' : '', - hasGrpcTimestamp ? 'GrpcTimestamp' : '', - hasService ? 'ServiceClient' : '', - ), - '} from', - `'${packageName}'`, - '\n\n', - content, - ), - ); -} - -export function createExportPackageName(packageName: string): string { - if (packageName) { - return `export const PACKAGE_NAME = '${packageName}'\n\n`; - } - - return ''; -} - -export function createExportEnums( - enums: TNamespaceEnum[], -): [string, ICachedEnumsProps] { - let content = ''; - const cachedEnums: ICachedEnumsProps = {}; - - enums.forEach((enu) => { - const [enumName, values] = enu; - cachedEnums[enumName] = true; - - const valuesAsStrings = values.reduce<[string, string, string]>( - (result, vals) => { - Object.entries(vals).forEach(([k, v]) => { - const camelizedKey = camelize(k); - - result[0] += `${camelizedKey}: ${v},\n`; - result[1] += `| '${camelizedKey}'\n`; - result[2] += `${v}: '${camelizedKey}',\n`; - }); - - return result; - }, - ['', '', ''], - ); - - content += ` - export const ${lowerFirstChar(enumName)} = { - ${valuesAsStrings[0]} - }; - - export const ${lowerFirstChar(enumName)}Mapper = { - ${valuesAsStrings[2]} - }; - - export type T${enumName} = ${valuesAsStrings[1]}; - `; - }); - - return [content, cachedEnums]; -} - -export function createExportMessages( - messages: TNamespaceMessage[], - cachedEnums: ICachedEnumsProps, -): [string, boolean] { - let content = ''; - let hasGrpcTimestamp = false; - - messages.forEach((message) => { - const [messageName, fields] = message; - - const fieldsAsString = fields.reduce( - (result, { name, optional, type, rule }) => { - const tsPrimitiveType = cachedEnums[type] - ? 'number' - : convertTypeScriptType(type); - const tsKey = [name, optional ? '?' : ''].join(''); - const tsType = [tsPrimitiveType, rule === 'repeated' ? '[]' : ''].join( - '', - ); - - result += `${tsKey}: ${tsType};`; - - if (type === 'google.protobuf.Timestamp') { - hasGrpcTimestamp = true; - } - - return result; - }, - '', - ); - - content += ` - export interface I${messageName} { - ${fieldsAsString} - } - `; - }); - - return [content, hasGrpcTimestamp]; -} - -export function createExportServices( - services: TNamespaceService[], -): [string, boolean] { - let content = ''; - let hasBanType = false; - - services.forEach((service) => { - const [serviceName, methods] = service; - - const methodsAsString = methods.reduce( - (result, { name, requestType, responseType }) => { - const tsType = convertTypeScriptRequestType(requestType); - const paramsAndResponse = `(params: ${tsType}, metadata?: Metadata): Promise;`; - - if (tsType === '{}') { - hasBanType = true; - } - - result += `${name}${paramsAndResponse}\n`; - result += `${ - name.charAt(0).toLowerCase() + name.slice(1) - }${paramsAndResponse}\n`; - - return result; - }, - '', - ); - - content += ` - export const ${toSnakeCase(serviceName).toUpperCase()} = '${serviceName}'; - - export interface I${serviceName} extends ServiceClient { - ${methodsAsString} - } - `; - }); - - return [content, hasBanType]; -} diff --git a/packages/cli/src/utils/debugger.ts b/packages/cli/src/utils/debugger.ts new file mode 100644 index 0000000..470baa2 --- /dev/null +++ b/packages/cli/src/utils/debugger.ts @@ -0,0 +1,12 @@ +/** + * internal usage when developing + */ + +export function sleep(second: number) { + const start = new Date().getTime(); + let end = start; + + while (end < start + second * 1000) { + end = new Date().getTime(); + } +} diff --git a/packages/cli/src/utils/index.ts b/packages/cli/src/utils/index.ts deleted file mode 100644 index 241382f..0000000 --- a/packages/cli/src/utils/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './configuration'; -export * from './formatter'; -export * from './parsers'; diff --git a/packages/cli/src/utils/objectUtils.ts b/packages/cli/src/utils/objectUtils.ts new file mode 100644 index 0000000..ca6c5f8 --- /dev/null +++ b/packages/cli/src/utils/objectUtils.ts @@ -0,0 +1,17 @@ +export function mergeObj(source: any, target: any): any { + Object.entries(target).forEach(([key, value]) => { + const sourceVal = source[key]; + + if (sourceVal) { + if (Array.isArray(sourceVal) && Array.isArray(value)) { + source[key].push(...value); + } else { + Object.assign(source[key], value); + } + } else { + source[key] = value; + } + }); + + return source; +} diff --git a/packages/cli/src/utils/parsers.ts b/packages/cli/src/utils/parsers.ts index 69067cf..fd99ad5 100644 --- a/packages/cli/src/utils/parsers.ts +++ b/packages/cli/src/utils/parsers.ts @@ -2,7 +2,7 @@ import type { IEnum, IService, INamespace, IType } from 'protobufjs'; import type { TEnum, - IOptionsProps, + IConfigProps, IMessageProps, IServiceProps, TNamespaceEnum, @@ -16,7 +16,7 @@ type TNamespace = Required['nested']; export function parse( namespace: TNamespace, - options: IOptionsProps = {}, + options: IConfigProps = {}, ): TParseNamespaceReturn[] { const { external = [] } = options; let result: TParseNamespaceReturn[] = []; diff --git a/packages/cli/src/utils/formatter.ts b/packages/cli/src/utils/stringUtils.ts similarity index 50% rename from packages/cli/src/utils/formatter.ts rename to packages/cli/src/utils/stringUtils.ts index b5218db..19d768f 100644 --- a/packages/cli/src/utils/formatter.ts +++ b/packages/cli/src/utils/stringUtils.ts @@ -1,47 +1,29 @@ -export function convertTypeScriptType(type: string): string { - switch (type) { - case 'double': - case 'float': - case 'int32': - case 'int64': - case 'uint32': - case 'uint64': - case 'sint32': - case 'sint64': - case 'fixed32': - case 'fixed64': - case 'sfixed32': - case 'sfixed64': - return 'number'; - case 'bool': - return 'boolean'; - case 'bytes': - return 'number[]'; - case 'google.protobuf.Timestamp': - return 'GrpcTimestamp'; - case 'string': - return 'string'; - default: - return `I${type}`; - } +export function capitalize(data: string): string { + return combine({ joinWith: '' }, data.charAt(0).toUpperCase(), data.slice(1)); } -export function convertTypeScriptRequestType(requestType: string): string { - switch (requestType) { - case 'google.protobuf.Empty': - return '{}'; - default: - return `I${requestType}`; - } +interface ICamelizeOptionsProps { + uppercase?: boolean; } -export function camelize(str: string): string { +export function camelize( + str: string, + opts: ICamelizeOptionsProps = {}, +): string { + const { uppercase = false } = opts; + return str?.replace(/^([A-Z])|[\s-_/]+(\w)/g, (_match, p1, p2) => { if (p2) { return p2.toUpperCase(); } - return p1.toLowerCase(); + const result = p1.toLowerCase(); + + if (uppercase) { + return capitalize(result); + } + + return result; }); } @@ -59,10 +41,6 @@ export function lowerFirstChar(str: string): string { return str.charAt(0).toLowerCase() + str.slice(1); } -export function wrapArray(data: T): T extends any[] ? T : T[] { - return (Array.isArray(data) ? data : [data]) as T extends any[] ? T : T[]; -} - interface ICombineOptionsProps { joinWith: string; }