@themainstack/communication 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/grpc/client-factory.d.ts +5 -0
- package/dist/grpc/client-factory.js +73 -0
- package/dist/grpc/errors.d.ts +8 -0
- package/dist/grpc/errors.js +24 -0
- package/dist/grpc/server-factory.d.ts +98 -0
- package/dist/grpc/server-factory.js +220 -0
- package/dist/grpc/types.d.ts +10 -0
- package/dist/grpc/types.js +2 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +33 -0
- package/dist/proto-generator/index.d.ts +36 -0
- package/dist/proto-generator/index.js +250 -0
- package/docs/GRPC_USAGE.md +203 -0
- package/docs/INTEGRATION_EXAMPLE.md +364 -0
- package/examples/full-grpc-demo.ts +139 -0
- package/examples/grpc/calculator.proto +22 -0
- package/examples/grpc/fee.proto +60 -0
- package/examples/protos/hero.proto +17 -0
- package/package.json +26 -0
- package/src/grpc/client-factory.ts +48 -0
- package/src/grpc/errors.ts +31 -0
- package/src/grpc/server-factory.ts +263 -0
- package/src/grpc/types.ts +11 -0
- package/src/index.ts +42 -0
- package/src/proto-generator/index.ts +293 -0
- package/tests/fixtures/dummy.proto +15 -0
- package/tests/fixtures/no-package.proto +15 -0
- package/tests/grpc/client-factory.spec.ts +44 -0
- package/tests/grpc/errors.spec.ts +31 -0
- package/tests/index.spec.ts +29 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import * as grpc from '@grpc/grpc-js';
|
|
2
|
+
import * as protoLoader from '@grpc/proto-loader';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
import { generateProtoFromMethods, MethodDefinition } from '../proto-generator';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Options for creating a gRPC server
|
|
9
|
+
*/
|
|
10
|
+
export interface GrpcServerOptions {
|
|
11
|
+
/** The package name (e.g., 'fee.v1') */
|
|
12
|
+
packageName: string;
|
|
13
|
+
/** The service name (e.g., 'FeeService') */
|
|
14
|
+
serviceName: string;
|
|
15
|
+
/** Port to listen on (default: 50051) */
|
|
16
|
+
port?: number;
|
|
17
|
+
/** Host to bind to (default: '0.0.0.0') */
|
|
18
|
+
host?: string;
|
|
19
|
+
/** Path to existing proto file (if not provided, will auto-generate) */
|
|
20
|
+
protoPath?: string;
|
|
21
|
+
/** Directory to save auto-generated proto (default: './grpc') */
|
|
22
|
+
protoOutputDir?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Method handler definition for the server
|
|
27
|
+
*/
|
|
28
|
+
export interface ServerMethodHandler<TReq = any, TRes = any> {
|
|
29
|
+
/** Method name (e.g., 'CalculateWithdrawalFee') */
|
|
30
|
+
name: string;
|
|
31
|
+
/** The actual handler function */
|
|
32
|
+
handler: (request: TReq) => Promise<TRes> | TRes;
|
|
33
|
+
/** Sample request for proto generation */
|
|
34
|
+
requestSample: () => TReq;
|
|
35
|
+
/** Sample response for proto generation */
|
|
36
|
+
responseSample: () => TRes;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* GrpcServerFactory - Create and run gRPC servers easily
|
|
41
|
+
*
|
|
42
|
+
* Usage:
|
|
43
|
+
* ```typescript
|
|
44
|
+
* const server = await GrpcServerFactory.createServer({
|
|
45
|
+
* packageName: 'fee.v1',
|
|
46
|
+
* serviceName: 'FeeService',
|
|
47
|
+
* port: 50053,
|
|
48
|
+
* }, [
|
|
49
|
+
* {
|
|
50
|
+
* name: 'CalculateWithdrawalFee',
|
|
51
|
+
* handler: async (req) => calculateFee(req),
|
|
52
|
+
* requestSample: () => ({ merchantId: '', amount: 0 }),
|
|
53
|
+
* responseSample: () => ({ dollarAmount: 0, fees: [] }),
|
|
54
|
+
* }
|
|
55
|
+
* ]);
|
|
56
|
+
*
|
|
57
|
+
* await server.start();
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export class GrpcServerFactory {
|
|
61
|
+
private server: grpc.Server;
|
|
62
|
+
private options: Required<GrpcServerOptions>;
|
|
63
|
+
private isRunning: boolean = false;
|
|
64
|
+
|
|
65
|
+
private constructor(
|
|
66
|
+
server: grpc.Server,
|
|
67
|
+
options: Required<GrpcServerOptions>
|
|
68
|
+
) {
|
|
69
|
+
this.server = server;
|
|
70
|
+
this.options = options;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Create a new gRPC server with the given handlers
|
|
75
|
+
*/
|
|
76
|
+
static async createServer(
|
|
77
|
+
options: GrpcServerOptions,
|
|
78
|
+
handlers: ServerMethodHandler[]
|
|
79
|
+
): Promise<GrpcServerFactory> {
|
|
80
|
+
const fullOptions: Required<GrpcServerOptions> = {
|
|
81
|
+
port: 50051,
|
|
82
|
+
host: '0.0.0.0',
|
|
83
|
+
protoOutputDir: './grpc',
|
|
84
|
+
protoPath: '',
|
|
85
|
+
...options,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Step 1: Generate or load proto file
|
|
89
|
+
let protoPath = fullOptions.protoPath;
|
|
90
|
+
|
|
91
|
+
if (!protoPath) {
|
|
92
|
+
// Auto-generate proto from handlers
|
|
93
|
+
const methodDefinitions: MethodDefinition<any, any>[] = handlers.map(h => ({
|
|
94
|
+
name: h.name,
|
|
95
|
+
requestSample: h.requestSample,
|
|
96
|
+
responseSample: h.responseSample,
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
const protoContent = generateProtoFromMethods(methodDefinitions, {
|
|
100
|
+
packageName: fullOptions.packageName,
|
|
101
|
+
serviceName: fullOptions.serviceName,
|
|
102
|
+
outputDir: fullOptions.protoOutputDir,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
protoPath = path.join(
|
|
106
|
+
fullOptions.protoOutputDir,
|
|
107
|
+
`${fullOptions.serviceName.toLowerCase().replace(/service$/, '')}.proto`
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
console.log(`Proto file auto-generated: ${protoPath}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Step 2: Load the proto definition
|
|
114
|
+
const packageDefinition = protoLoader.loadSync(protoPath, {
|
|
115
|
+
keepCase: false, // Convert snake_case to camelCase
|
|
116
|
+
longs: String,
|
|
117
|
+
enums: String,
|
|
118
|
+
defaults: true,
|
|
119
|
+
oneofs: true,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const protoDescriptor = grpc.loadPackageDefinition(packageDefinition);
|
|
123
|
+
|
|
124
|
+
// Step 3: Navigate to the service definition
|
|
125
|
+
let serviceDefinition: any = protoDescriptor;
|
|
126
|
+
const packageParts = fullOptions.packageName.split('.');
|
|
127
|
+
for (const part of packageParts) {
|
|
128
|
+
serviceDefinition = serviceDefinition[part];
|
|
129
|
+
if (!serviceDefinition) {
|
|
130
|
+
throw new Error(`Package '${fullOptions.packageName}' not found in proto`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const ServiceConstructor = serviceDefinition[fullOptions.serviceName];
|
|
135
|
+
if (!ServiceConstructor || !ServiceConstructor.service) {
|
|
136
|
+
throw new Error(`Service '${fullOptions.serviceName}' not found in package '${fullOptions.packageName}'`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Step 4: Create handler implementations
|
|
140
|
+
const implementations: Record<string, grpc.handleUnaryCall<any, any>> = {};
|
|
141
|
+
|
|
142
|
+
for (const handler of handlers) {
|
|
143
|
+
// gRPC uses lowercase first letter for method names in implementation
|
|
144
|
+
const methodName = handler.name.charAt(0).toLowerCase() + handler.name.slice(1);
|
|
145
|
+
|
|
146
|
+
implementations[methodName] = async (
|
|
147
|
+
call: grpc.ServerUnaryCall<any, any>,
|
|
148
|
+
callback: grpc.sendUnaryData<any>
|
|
149
|
+
) => {
|
|
150
|
+
try {
|
|
151
|
+
const result = await handler.handler(call.request);
|
|
152
|
+
callback(null, result);
|
|
153
|
+
} catch (error: any) {
|
|
154
|
+
console.error(`gRPC Error in ${handler.name}:`, error);
|
|
155
|
+
callback({
|
|
156
|
+
code: grpc.status.INTERNAL,
|
|
157
|
+
message: error.message || 'Internal server error',
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Step 5: Create and configure the server
|
|
164
|
+
const server = new grpc.Server();
|
|
165
|
+
server.addService(ServiceConstructor.service, implementations);
|
|
166
|
+
|
|
167
|
+
return new GrpcServerFactory(server, { ...fullOptions, protoPath });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Start the gRPC server
|
|
172
|
+
*/
|
|
173
|
+
async start(): Promise<void> {
|
|
174
|
+
return new Promise((resolve, reject) => {
|
|
175
|
+
const address = `${this.options.host}:${this.options.port}`;
|
|
176
|
+
|
|
177
|
+
this.server.bindAsync(
|
|
178
|
+
address,
|
|
179
|
+
grpc.ServerCredentials.createInsecure(),
|
|
180
|
+
(error, port) => {
|
|
181
|
+
if (error) {
|
|
182
|
+
reject(error);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
this.isRunning = true;
|
|
187
|
+
console.log(` gRPC Server running on ${address}`);
|
|
188
|
+
console.log(` Service: ${this.options.serviceName}`);
|
|
189
|
+
console.log(` Package: ${this.options.packageName}`);
|
|
190
|
+
resolve();
|
|
191
|
+
}
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Stop the gRPC server
|
|
198
|
+
*/
|
|
199
|
+
async stop(): Promise<void> {
|
|
200
|
+
return new Promise((resolve) => {
|
|
201
|
+
this.server.tryShutdown(() => {
|
|
202
|
+
this.isRunning = false;
|
|
203
|
+
console.log('gRPC Server stopped');
|
|
204
|
+
resolve();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Force stop the server immediately
|
|
211
|
+
*/
|
|
212
|
+
forceStop(): void {
|
|
213
|
+
this.server.forceShutdown();
|
|
214
|
+
this.isRunning = false;
|
|
215
|
+
console.log('gRPC Server force stopped');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Check if server is running
|
|
220
|
+
*/
|
|
221
|
+
get running(): boolean {
|
|
222
|
+
return this.isRunning;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Get the server address
|
|
227
|
+
*/
|
|
228
|
+
get address(): string {
|
|
229
|
+
return `${this.options.host}:${this.options.port}`;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Helper function to quickly expose a function as a gRPC service
|
|
235
|
+
*
|
|
236
|
+
* Usage:
|
|
237
|
+
* ```typescript
|
|
238
|
+
* const server = await exposeAsGrpc(
|
|
239
|
+
* 'CalculateFee',
|
|
240
|
+
* async (req) => ({ fee: req.amount * 0.02 }),
|
|
241
|
+
* { requestSample: () => ({ amount: 100 }), responseSample: () => ({ fee: 2 }) },
|
|
242
|
+
* { packageName: 'fee.v1', serviceName: 'FeeService', port: 50053 }
|
|
243
|
+
* );
|
|
244
|
+
* ```
|
|
245
|
+
*/
|
|
246
|
+
export async function exposeAsGrpc<TReq extends object, TRes extends object>(
|
|
247
|
+
methodName: string,
|
|
248
|
+
handler: (request: TReq) => Promise<TRes> | TRes,
|
|
249
|
+
samples: { requestSample: () => TReq; responseSample: () => TRes },
|
|
250
|
+
options: GrpcServerOptions
|
|
251
|
+
): Promise<GrpcServerFactory> {
|
|
252
|
+
const server = await GrpcServerFactory.createServer(options, [
|
|
253
|
+
{
|
|
254
|
+
name: methodName,
|
|
255
|
+
handler,
|
|
256
|
+
requestSample: samples.requestSample,
|
|
257
|
+
responseSample: samples.responseSample,
|
|
258
|
+
},
|
|
259
|
+
]);
|
|
260
|
+
|
|
261
|
+
await server.start();
|
|
262
|
+
return server;
|
|
263
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ChannelOptions, ChannelCredentials } from '@grpc/grpc-js';
|
|
2
|
+
|
|
3
|
+
export interface GrpcClientOptions {
|
|
4
|
+
serviceName: string;
|
|
5
|
+
packageName?: string;
|
|
6
|
+
protoPath: string;
|
|
7
|
+
url: string;
|
|
8
|
+
credentials?: ChannelCredentials;
|
|
9
|
+
channelOptions?: ChannelOptions;
|
|
10
|
+
loaderOptions?: object;
|
|
11
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @themainstack/communication
|
|
3
|
+
*
|
|
4
|
+
* A unified gRPC framework for inter-service communication.
|
|
5
|
+
*
|
|
6
|
+
* Workflow:
|
|
7
|
+
* 1. Proto Generation - Auto-generate .proto files from TypeScript
|
|
8
|
+
* 2. Server Factory - Expose functions as gRPC endpoints
|
|
9
|
+
* 3. Client Factory - Call gRPC services from other services
|
|
10
|
+
* 4. Error Handling - Standardized error translation
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Proto Generation
|
|
14
|
+
// Auto-generate .proto files from TypeScript types
|
|
15
|
+
export {
|
|
16
|
+
generateProtoFromMethods,
|
|
17
|
+
generateProtoFromFunction
|
|
18
|
+
} from './proto-generator';
|
|
19
|
+
export type {
|
|
20
|
+
MethodDefinition,
|
|
21
|
+
GenerateProtoOptions
|
|
22
|
+
} from './proto-generator';
|
|
23
|
+
|
|
24
|
+
// Server Factory
|
|
25
|
+
// Expose existing functions as gRPC endpoints
|
|
26
|
+
export {
|
|
27
|
+
GrpcServerFactory,
|
|
28
|
+
exposeAsGrpc
|
|
29
|
+
} from './grpc/server-factory';
|
|
30
|
+
export type {
|
|
31
|
+
GrpcServerOptions,
|
|
32
|
+
ServerMethodHandler
|
|
33
|
+
} from './grpc/server-factory';
|
|
34
|
+
|
|
35
|
+
// Client Factory
|
|
36
|
+
// Create clients to call gRPC services
|
|
37
|
+
export { GrpcClientFactory } from './grpc/client-factory';
|
|
38
|
+
export type { GrpcClientOptions } from './grpc/types';
|
|
39
|
+
|
|
40
|
+
// Error Handling
|
|
41
|
+
// Standardized gRPC error translation
|
|
42
|
+
export { handleGrpcError, GrpcError } from './grpc/errors';
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Maps JavaScript/TypeScript runtime types to Protobuf types.
|
|
6
|
+
*/
|
|
7
|
+
type ProtoType = "double" | "float" | "int32" | "int64" | "bool" | "string" | "bytes" | string;
|
|
8
|
+
|
|
9
|
+
interface ProtoField {
|
|
10
|
+
name: string;
|
|
11
|
+
type: ProtoType;
|
|
12
|
+
rule?: "repeated";
|
|
13
|
+
id: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ProtoMessage {
|
|
17
|
+
name: string;
|
|
18
|
+
fields: ProtoField[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ProtoMethod {
|
|
22
|
+
name: string;
|
|
23
|
+
requestType: string;
|
|
24
|
+
responseType: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Options for generating a proto file
|
|
29
|
+
*/
|
|
30
|
+
export interface GenerateProtoOptions {
|
|
31
|
+
/** The package name (e.g., 'fee.v1') */
|
|
32
|
+
packageName?: string;
|
|
33
|
+
/** The service name (e.g., 'FeeService') */
|
|
34
|
+
serviceName?: string;
|
|
35
|
+
/** Directory to save the proto file (if not provided, won't save to file) */
|
|
36
|
+
outputDir?: string;
|
|
37
|
+
/** Output filename (default: derived from serviceName or 'generated.proto') */
|
|
38
|
+
outputFilename?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Definition of a gRPC method with request and response generators
|
|
43
|
+
*/
|
|
44
|
+
export interface MethodDefinition<TReq extends object, TRes extends object> {
|
|
45
|
+
/** Method name (e.g., 'CalculateWithdrawalFee') */
|
|
46
|
+
name: string;
|
|
47
|
+
/** Function that returns a sample request object */
|
|
48
|
+
requestSample: () => TReq;
|
|
49
|
+
/** Function that returns a sample response object */
|
|
50
|
+
responseSample: () => TRes;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Generate a complete .proto file from method definitions
|
|
55
|
+
*
|
|
56
|
+
* @param methods Array of method definitions
|
|
57
|
+
* @param options Generation options
|
|
58
|
+
* @returns The generated proto string
|
|
59
|
+
*/
|
|
60
|
+
export function generateProtoFromMethods(
|
|
61
|
+
methods: MethodDefinition<any, any>[],
|
|
62
|
+
options: GenerateProtoOptions = {}
|
|
63
|
+
): string {
|
|
64
|
+
const { packageName, serviceName = 'GeneratedService', outputDir, outputFilename } = options;
|
|
65
|
+
|
|
66
|
+
const messages: ProtoMessage[] = [];
|
|
67
|
+
const protoMethods: ProtoMethod[] = [];
|
|
68
|
+
const processedObjects = new Set<object>();
|
|
69
|
+
|
|
70
|
+
// Helper: Capitalize string
|
|
71
|
+
function capitalize(s: string): string {
|
|
72
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Helper: Convert camelCase to snake_case
|
|
76
|
+
function toSnakeCase(s: string): string {
|
|
77
|
+
return s.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, '');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Recursive function to analyze object and generate messages
|
|
81
|
+
function analyzeMessage(obj: any, msgName: string): string {
|
|
82
|
+
if (obj instanceof Date) return "string";
|
|
83
|
+
|
|
84
|
+
if (processedObjects.has(obj)) {
|
|
85
|
+
console.warn(`Circular reference detected for ${msgName}. Breaking recursion.`);
|
|
86
|
+
return "string";
|
|
87
|
+
}
|
|
88
|
+
processedObjects.add(obj);
|
|
89
|
+
|
|
90
|
+
const fields: ProtoField[] = [];
|
|
91
|
+
let fieldIdCounter = 1;
|
|
92
|
+
|
|
93
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
94
|
+
let type: ProtoType = "string";
|
|
95
|
+
let rule: "repeated" | undefined = undefined;
|
|
96
|
+
|
|
97
|
+
const valueType = typeof value;
|
|
98
|
+
|
|
99
|
+
if (value === null || value === undefined) {
|
|
100
|
+
type = "string";
|
|
101
|
+
} else if (valueType === "string") {
|
|
102
|
+
type = "string";
|
|
103
|
+
} else if (valueType === "boolean") {
|
|
104
|
+
type = "bool";
|
|
105
|
+
} else if (valueType === "number") {
|
|
106
|
+
type = Number.isInteger(value) ? "int32" : "double";
|
|
107
|
+
} else if (Array.isArray(value)) {
|
|
108
|
+
rule = "repeated";
|
|
109
|
+
if (value.length > 0) {
|
|
110
|
+
const firstItem = value[0];
|
|
111
|
+
if (typeof firstItem === "object") {
|
|
112
|
+
const nestedName = capitalize(key).replace(/s$/, "");
|
|
113
|
+
type = analyzeMessage(firstItem, nestedName);
|
|
114
|
+
} else {
|
|
115
|
+
type = typeof firstItem === "number"
|
|
116
|
+
? (Number.isInteger(firstItem) ? "int32" : "double")
|
|
117
|
+
: typeof firstItem === "boolean" ? "bool" : "string";
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
type = "string";
|
|
121
|
+
}
|
|
122
|
+
} else if (valueType === "object") {
|
|
123
|
+
const nestedName = capitalize(key);
|
|
124
|
+
type = analyzeMessage(value, nestedName);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Use snake_case for proto fields
|
|
128
|
+
fields.push({ name: toSnakeCase(key), type, rule, id: fieldIdCounter++ });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Check if message already exists (same name)
|
|
132
|
+
const existingIndex = messages.findIndex(m => m.name === msgName);
|
|
133
|
+
if (existingIndex === -1) {
|
|
134
|
+
messages.push({ name: msgName, fields });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return msgName;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Process each method
|
|
141
|
+
for (const method of methods) {
|
|
142
|
+
const requestTypeName = `${method.name}Request`;
|
|
143
|
+
const responseTypeName = `${method.name}Response`;
|
|
144
|
+
|
|
145
|
+
// Analyze request and response
|
|
146
|
+
const requestSample = method.requestSample();
|
|
147
|
+
const responseSample = method.responseSample();
|
|
148
|
+
|
|
149
|
+
analyzeMessage(requestSample, requestTypeName);
|
|
150
|
+
processedObjects.clear(); // Reset for next analysis
|
|
151
|
+
analyzeMessage(responseSample, responseTypeName);
|
|
152
|
+
processedObjects.clear();
|
|
153
|
+
|
|
154
|
+
protoMethods.push({
|
|
155
|
+
name: method.name,
|
|
156
|
+
requestType: requestTypeName,
|
|
157
|
+
responseType: responseTypeName,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Format the proto file
|
|
162
|
+
const lines: string[] = ['syntax = "proto3";', ''];
|
|
163
|
+
|
|
164
|
+
if (packageName) {
|
|
165
|
+
lines.push(`package ${packageName};`, '');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Service definition
|
|
169
|
+
lines.push(`// ${serviceName} - Auto-generated gRPC service`);
|
|
170
|
+
lines.push(`service ${serviceName} {`);
|
|
171
|
+
for (const method of protoMethods) {
|
|
172
|
+
lines.push(` rpc ${method.name} (${method.requestType}) returns (${method.responseType}) {}`);
|
|
173
|
+
}
|
|
174
|
+
lines.push('}', '');
|
|
175
|
+
|
|
176
|
+
// Message definitions (reverse for proper dependency order)
|
|
177
|
+
for (const msg of messages.reverse()) {
|
|
178
|
+
lines.push(`message ${msg.name} {`);
|
|
179
|
+
for (const field of msg.fields) {
|
|
180
|
+
const rulePrefix = field.rule ? `${field.rule} ` : "";
|
|
181
|
+
lines.push(` ${rulePrefix}${field.type} ${field.name} = ${field.id};`);
|
|
182
|
+
}
|
|
183
|
+
lines.push('}', '');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const protoContent = lines.join('\n');
|
|
187
|
+
|
|
188
|
+
// Save to file if outputDir is specified
|
|
189
|
+
if (outputDir) {
|
|
190
|
+
const filename = outputFilename || `${toSnakeCase(serviceName).replace(/_service$/, '')}.proto`;
|
|
191
|
+
const fullPath = path.join(outputDir, filename);
|
|
192
|
+
|
|
193
|
+
// Create directory if it doesn't exist
|
|
194
|
+
if (!fs.existsSync(outputDir)) {
|
|
195
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
fs.writeFileSync(fullPath, protoContent);
|
|
199
|
+
console.log(`✅ Proto file generated: ${fullPath}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return protoContent;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Convenience function for single-function proto generation (original API)
|
|
207
|
+
*/
|
|
208
|
+
export function generateProtoFromFunction<T extends object>(
|
|
209
|
+
generatorFn: () => T,
|
|
210
|
+
rootMessageName: string = "RootMessage"
|
|
211
|
+
): string {
|
|
212
|
+
const messages: ProtoMessage[] = [];
|
|
213
|
+
const processedObjects = new Set<object>();
|
|
214
|
+
|
|
215
|
+
function capitalize(s: string): string {
|
|
216
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function toSnakeCase(s: string): string {
|
|
220
|
+
return s.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, '');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function analyzeMessage(obj: any, msgName: string): string {
|
|
224
|
+
if (obj instanceof Date) return "string";
|
|
225
|
+
|
|
226
|
+
if (processedObjects.has(obj)) {
|
|
227
|
+
return "string";
|
|
228
|
+
}
|
|
229
|
+
processedObjects.add(obj);
|
|
230
|
+
|
|
231
|
+
const fields: ProtoField[] = [];
|
|
232
|
+
let fieldIdCounter = 1;
|
|
233
|
+
|
|
234
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
235
|
+
let type: ProtoType = "string";
|
|
236
|
+
let rule: "repeated" | undefined = undefined;
|
|
237
|
+
|
|
238
|
+
const valueType = typeof value;
|
|
239
|
+
|
|
240
|
+
if (value === null || value === undefined) {
|
|
241
|
+
type = "string";
|
|
242
|
+
} else if (valueType === "string") {
|
|
243
|
+
type = "string";
|
|
244
|
+
} else if (valueType === "boolean") {
|
|
245
|
+
type = "bool";
|
|
246
|
+
} else if (valueType === "number") {
|
|
247
|
+
type = Number.isInteger(value) ? "int32" : "double";
|
|
248
|
+
} else if (Array.isArray(value)) {
|
|
249
|
+
rule = "repeated";
|
|
250
|
+
if (value.length > 0) {
|
|
251
|
+
const firstItem = value[0];
|
|
252
|
+
if (typeof firstItem === "object") {
|
|
253
|
+
const nestedName = capitalize(key).replace(/s$/, "");
|
|
254
|
+
type = analyzeMessage(firstItem, nestedName);
|
|
255
|
+
} else {
|
|
256
|
+
type = typeof firstItem === "number"
|
|
257
|
+
? (Number.isInteger(firstItem) ? "int32" : "double")
|
|
258
|
+
: typeof firstItem === "boolean" ? "bool" : "string";
|
|
259
|
+
}
|
|
260
|
+
} else {
|
|
261
|
+
type = "string";
|
|
262
|
+
}
|
|
263
|
+
} else if (valueType === "object") {
|
|
264
|
+
const nestedName = capitalize(key);
|
|
265
|
+
type = analyzeMessage(value, nestedName);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
fields.push({ name: toSnakeCase(key), type, rule, id: fieldIdCounter++ });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
messages.push({ name: msgName, fields });
|
|
272
|
+
return msgName;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const sampleData = generatorFn();
|
|
276
|
+
if (!sampleData || typeof sampleData !== "object") {
|
|
277
|
+
throw new Error("Generator function must return a non-null object.");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
analyzeMessage(sampleData, rootMessageName);
|
|
281
|
+
|
|
282
|
+
const lines: string[] = ['syntax = "proto3";', ''];
|
|
283
|
+
for (const msg of messages.reverse()) {
|
|
284
|
+
lines.push(`message ${msg.name} {`);
|
|
285
|
+
for (const field of msg.fields) {
|
|
286
|
+
const rulePrefix = field.rule ? `${field.rule} ` : "";
|
|
287
|
+
lines.push(` ${rulePrefix}${field.type} ${field.name} = ${field.id};`);
|
|
288
|
+
}
|
|
289
|
+
lines.push('}', '');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return lines.join('\n');
|
|
293
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import * as grpc from '@grpc/grpc-js';
|
|
4
|
+
import { GrpcClientFactory } from '../../src/grpc/client-factory';
|
|
5
|
+
|
|
6
|
+
describe('GrpcClientFactory', () => {
|
|
7
|
+
const protoPath = join(__dirname, '../fixtures/dummy.proto');
|
|
8
|
+
const noPackageProtoPath = join(__dirname, '../fixtures/no-package.proto');
|
|
9
|
+
|
|
10
|
+
it('should create a client successfully (Strict Package)', () => {
|
|
11
|
+
const client = GrpcClientFactory.createClient<grpc.Client>({
|
|
12
|
+
packageName: 'test.v1',
|
|
13
|
+
serviceName: 'GreeterService',
|
|
14
|
+
protoPath,
|
|
15
|
+
url: 'localhost:50051',
|
|
16
|
+
});
|
|
17
|
+
expect(client).toBeDefined();
|
|
18
|
+
client.close();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should create a client successfully for ROOT service (Implicit Default)', () => {
|
|
22
|
+
const client = GrpcClientFactory.createClient<grpc.Client>({
|
|
23
|
+
// packageName omitted -> defaults to '' (Root)
|
|
24
|
+
serviceName: 'RootService',
|
|
25
|
+
protoPath: noPackageProtoPath,
|
|
26
|
+
url: 'localhost:50051',
|
|
27
|
+
});
|
|
28
|
+
expect(client).toBeDefined();
|
|
29
|
+
client.close();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should throw error if implicit default fails (expecting root, but service is missing)', () => {
|
|
33
|
+
// Relying on default '' means looking at root.
|
|
34
|
+
// But GreeterService is inside test.v1, so it should NOT be found at root.
|
|
35
|
+
expect(() => {
|
|
36
|
+
GrpcClientFactory.createClient<grpc.Client>({
|
|
37
|
+
// packageName omitted -> defaults to root
|
|
38
|
+
serviceName: 'GreeterService',
|
|
39
|
+
protoPath, // This proto puts it in 'test.v1'
|
|
40
|
+
url: 'localhost:50051',
|
|
41
|
+
});
|
|
42
|
+
}).toThrow(/not found in root definition/);
|
|
43
|
+
});
|
|
44
|
+
});
|