stacked-server-typescript-types 1.1.3

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.
@@ -0,0 +1,747 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Process OpenAPI JSON files and generate TypeScript types
5
+ * that combine message types from ts-proto with endpoint types from OpenAPI
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ const openapiJsonFile = process.argv[2];
12
+ const outputFile = process.argv[3];
13
+ const serviceName = process.argv[4];
14
+ const messagesDir = process.argv[5];
15
+ const serverDir = process.argv[6] || path.join(__dirname, '..');
16
+
17
+ if (!openapiJsonFile || !outputFile || !serviceName) {
18
+ console.error('Usage: process-openapi-types.js <openapi-json> <output-file> <service-name> <messages-dir> [server-dir]');
19
+ process.exit(1);
20
+ }
21
+
22
+ if (!fs.existsSync(openapiJsonFile)) {
23
+ console.error(`OpenAPI JSON file not found: ${openapiJsonFile}`);
24
+ process.exit(1);
25
+ }
26
+
27
+ // Read OpenAPI spec
28
+ const openapiSpec = JSON.parse(fs.readFileSync(openapiJsonFile, 'utf8'));
29
+
30
+ // Normalize service name for matching
31
+ const normalizedServiceName = (serviceName || '')
32
+ .toLowerCase()
33
+ .replace(/service$/i, '')
34
+ .replace(/-/g, '');
35
+
36
+ // Find all message type files
37
+ const messageTypes = new Set();
38
+ if (fs.existsSync(messagesDir)) {
39
+ // Include nested directories (e.g., messages/google/protobuf) so types like BoolValue resolve.
40
+ function walkMessages(dir) {
41
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
42
+ for (const entry of entries) {
43
+ const full = path.join(dir, entry.name);
44
+ if (entry.isDirectory()) {
45
+ walkMessages(full);
46
+ } else if (entry.isFile() && entry.name.endsWith('.ts') && entry.name !== 'index.ts') {
47
+ const basename = path.basename(entry.name, '.ts');
48
+ messageTypes.add(basename);
49
+ }
50
+ }
51
+ }
52
+
53
+ walkMessages(messagesDir);
54
+ }
55
+
56
+ // Parse proto files to map RPC names to request/response message names
57
+ function buildProtoRpcMap() {
58
+ const protoRoot = path.join(serverDir, 'services');
59
+ const rpcMap = {};
60
+
61
+ function walkDir(dir) {
62
+ if (!fs.existsSync(dir)) return;
63
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
64
+ for (const entry of entries) {
65
+ const full = path.join(dir, entry.name);
66
+ if (entry.isDirectory()) {
67
+ walkDir(full);
68
+ } else if (entry.isFile() && entry.name.endsWith('.proto')) {
69
+ try {
70
+ const content = fs.readFileSync(full, 'utf8');
71
+
72
+ // Derive service dir name (first component under protoRoot) to avoid cross-service collisions.
73
+ const rel = path.relative(protoRoot, full);
74
+ const parts = rel.split(path.sep);
75
+ const serviceDir = parts.length > 1 ? parts[0] : '';
76
+ const normalizedDir = serviceDir
77
+ .toLowerCase()
78
+ .replace(/-service$/i, '')
79
+ .replace(/service$/i, '')
80
+ .replace(/-/g, '');
81
+
82
+ const isCurrentService = !serviceDir
83
+ ? true
84
+ : (normalizedDir === normalizedServiceName ||
85
+ normalizedDir.includes(normalizedServiceName) ||
86
+ normalizedServiceName.includes(normalizedDir));
87
+
88
+ if (!isCurrentService) continue;
89
+
90
+ const svcRegex = /service\s+\w+\s*\{([\s\S]*?)\n\}/gm;
91
+ const rpcRegex = /rpc\s+(\w+)\s*\(\s*([\w.]+)\s*\)\s*returns\s*\(\s*([\w.]+)\s*\)/g;
92
+
93
+ let svc;
94
+ while ((svc = svcRegex.exec(content)) !== null) {
95
+ const body = svc[1];
96
+ let m;
97
+ while ((m = rpcRegex.exec(body)) !== null) {
98
+ const rpcName = m[1];
99
+ const request = m[2];
100
+ const response = m[3];
101
+ // Same rpcName can exist across services; within a service it should be unique.
102
+ rpcMap[rpcName] = { request, response };
103
+ }
104
+ }
105
+ } catch (e) {
106
+ // ignore parse errors
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ walkDir(protoRoot);
113
+ return rpcMap;
114
+ }
115
+
116
+ // Build a map of rpcName -> leading comment block (2-5 sentences) from .proto files.
117
+ // We treat consecutive //-comments immediately above the rpc line as the doc.
118
+ function buildProtoRpcDocsMap() {
119
+ const protoRoot = path.join(serverDir, 'services');
120
+ const docsMap = {};
121
+
122
+ function normalizeDoc(lines) {
123
+ const cleaned = (lines || [])
124
+ .map(l => String(l).trim())
125
+ .map(l => l.replace(/^\/\/\s?/, '').trim())
126
+ .filter(Boolean);
127
+ return cleaned.join('\n');
128
+ }
129
+
130
+ function walkDir(dir) {
131
+ if (!fs.existsSync(dir)) return;
132
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
133
+ for (const entry of entries) {
134
+ const full = path.join(dir, entry.name);
135
+ if (entry.isDirectory()) {
136
+ walkDir(full);
137
+ } else if (entry.isFile() && entry.name.endsWith('.proto')) {
138
+ try {
139
+ const content = fs.readFileSync(full, 'utf8');
140
+
141
+ // Filter to current service's proto files.
142
+ const rel = path.relative(protoRoot, full);
143
+ const parts = rel.split(path.sep);
144
+ const serviceDir = parts.length > 1 ? parts[0] : '';
145
+ const normalizedDir = serviceDir
146
+ .toLowerCase()
147
+ .replace(/-service$/i, '')
148
+ .replace(/service$/i, '')
149
+ .replace(/-/g, '');
150
+
151
+ const isCurrentService = !serviceDir
152
+ ? true
153
+ : (normalizedDir === normalizedServiceName ||
154
+ normalizedDir.includes(normalizedServiceName) ||
155
+ normalizedServiceName.includes(normalizedDir));
156
+
157
+ if (!isCurrentService) continue;
158
+
159
+ const lines = content.split(/\r?\n/);
160
+ let commentBuf = [];
161
+ for (let i = 0; i < lines.length; i++) {
162
+ const line = lines[i];
163
+ const commentMatch = line.match(/^\s*\/\//);
164
+ if (commentMatch) {
165
+ commentBuf.push(line);
166
+ continue;
167
+ }
168
+
169
+ const rpcMatch = line.match(/^\s*rpc\s+(\w+)\s*\(/);
170
+ if (rpcMatch) {
171
+ const rpcName = rpcMatch[1];
172
+ const doc = normalizeDoc(commentBuf);
173
+ if (doc) docsMap[rpcName] = doc;
174
+ commentBuf = [];
175
+ continue;
176
+ }
177
+
178
+ // Reset buffer on non-comment lines (blank lines end the doc block)
179
+ if (String(line).trim() === '') {
180
+ commentBuf = [];
181
+ } else {
182
+ commentBuf = [];
183
+ }
184
+ }
185
+ } catch (e) {
186
+ // ignore parse errors
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ walkDir(protoRoot);
193
+ return docsMap;
194
+ }
195
+
196
+ const protoRpcMap = buildProtoRpcMap();
197
+ const protoRpcDocsMap = buildProtoRpcDocsMap();
198
+
199
+ // Build a map of proto message names to their fields (used to generate GET query params)
200
+ // ONLY from the current service's proto files
201
+ const protoMessageMap = (function buildProtoMessageMap() {
202
+ const protoRoot = path.join(serverDir, 'services');
203
+ const map = {};
204
+
205
+ function walkDir(dir) {
206
+ if (!fs.existsSync(dir)) return;
207
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
208
+ for (const entry of entries) {
209
+ const full = path.join(dir, entry.name);
210
+ if (entry.isDirectory()) {
211
+ walkDir(full);
212
+ } else if (entry.isFile() && entry.name.endsWith('.proto')) {
213
+ try {
214
+ const content = fs.readFileSync(full, 'utf8');
215
+ const msgRegex = /message\s+(\w+)\s*\{([\s\S]*?)\n\}/gm;
216
+ let m;
217
+
218
+ // Derive service dir name (first component under protoRoot)
219
+ const rel = path.relative(protoRoot, full);
220
+ const parts = rel.split(path.sep);
221
+ const serviceDir = parts.length > 1 ? parts[0] : '';
222
+
223
+ // Normalize the service directory for comparison
224
+ const normalizedDir = serviceDir
225
+ .toLowerCase()
226
+ .replace(/-service$/i, '')
227
+ .replace(/service$/i, '')
228
+ .replace(/-/g, '');
229
+
230
+ // Only process messages from the current service
231
+ const isCurrentService = normalizedDir === normalizedServiceName ||
232
+ normalizedDir.includes(normalizedServiceName) ||
233
+ normalizedServiceName.includes(normalizedDir);
234
+
235
+ if (!isCurrentService && serviceDir !== '') {
236
+ return; // Skip messages from other services
237
+ }
238
+
239
+ while ((m = msgRegex.exec(content)) !== null) {
240
+ const msgName = m[1];
241
+ const body = m[2];
242
+ const fields = [];
243
+ const fieldRegex = /(?:repeated\s+)?(map<[^>]+>|[A-Za-z0-9_.]+)\s+(\w+)\s*=\s*\d+\s*;/g;
244
+ let f;
245
+ while ((f = fieldRegex.exec(body)) !== null) {
246
+ const rawType = f[1];
247
+ const fieldName = f[2];
248
+ let tsType = 'any';
249
+ if (/^string$/.test(rawType) || /^bytes$/.test(rawType)) tsType = 'string';
250
+ else if (/^(?:int|uint|sint|fixed|sfixed)\w*/.test(rawType) || /^(?:double|float)$/.test(rawType)) tsType = 'number';
251
+ else if (/^bool$/.test(rawType)) tsType = 'boolean';
252
+ else if (/^map</.test(rawType)) tsType = 'Record<string,string>';
253
+ else if (/^repeated\s+/.test(f[0]) || rawType.startsWith('repeated ')) tsType = 'any[]';
254
+ fields.push({ name: fieldName, type: rawType, tsType });
255
+ }
256
+ // Only store if we don't already have this message (first match wins for current service)
257
+ if (!map[msgName]) {
258
+ map[msgName] = fields;
259
+ }
260
+ }
261
+ } catch (e) {
262
+ // ignore parse errors
263
+ }
264
+ }
265
+ }
266
+ }
267
+
268
+ walkDir(protoRoot);
269
+ return map;
270
+ })();
271
+
272
+ // Helper to get proto message fields (now simple since we only have current service's messages)
273
+ function getProtoFieldsForMessage(msgName) {
274
+ if (!msgName || !protoMessageMap[msgName]) return null;
275
+ return protoMessageMap[msgName];
276
+ }
277
+
278
+ // Helper to convert snake_case to camelCase
279
+ function toCamelCase(str) {
280
+ return str.replace(/[_-]([a-z])/g, (_, letter) => letter.toUpperCase());
281
+ }
282
+
283
+ // Helper to convert to PascalCase
284
+ function toPascalCase(str) {
285
+ const camel = toCamelCase(str);
286
+ return camel.charAt(0).toUpperCase() + camel.slice(1);
287
+ }
288
+
289
+ // Extract message type name from schema reference
290
+ function extractMessageType(ref) {
291
+ if (!ref || typeof ref !== 'string') return null;
292
+ const match = ref.match(/#\/components\/schemas\/(.+)/);
293
+ if (match) {
294
+ return match[1];
295
+ }
296
+ return null;
297
+ }
298
+
299
+ // Check if a type is a message type we generated
300
+ function isMessageType(typeName) {
301
+ if (messageTypes.has(typeName)) {
302
+ return true;
303
+ }
304
+ for (const msgType of messageTypes) {
305
+ if (msgType.toLowerCase() === typeName.toLowerCase()) {
306
+ return msgType;
307
+ }
308
+ }
309
+ return false;
310
+ }
311
+
312
+ // Generate TypeScript type from OpenAPI schema
313
+ function generateTypeFromSchema(schema, indent = '') {
314
+ if (!schema) return 'any';
315
+
316
+ if (schema.$ref) {
317
+ const typeName = extractMessageType(schema.$ref);
318
+ if (typeName) {
319
+ const msgType = isMessageType(typeName);
320
+ if (msgType) {
321
+ const actualName = typeof msgType === 'string' ? msgType : typeName;
322
+ return `Messages.${actualName}`;
323
+ }
324
+ return toPascalCase(typeName);
325
+ }
326
+ return 'any';
327
+ }
328
+
329
+ if (schema.type === 'object' && schema.properties) {
330
+ const props = Object.entries(schema.properties).map(([key, prop]) => {
331
+ const optional = schema.required && !schema.required.includes(key) ? '?' : '';
332
+ const propType = generateTypeFromSchema(prop, indent + ' ');
333
+ return `${indent} ${key}${optional}: ${propType};`;
334
+ }).join('\n');
335
+ return `{\n${props}\n${indent}}`;
336
+ }
337
+
338
+ if (schema.type === 'array') {
339
+ const itemType = generateTypeFromSchema(schema.items, indent);
340
+ return `${itemType}[]`;
341
+ }
342
+
343
+ if (schema.type === 'string') {
344
+ if (schema.enum) {
345
+ return schema.enum.map(v => `'${v}'`).join(' | ');
346
+ }
347
+ return 'string';
348
+ }
349
+
350
+ if (schema.type === 'integer' || schema.type === 'number') {
351
+ return 'number';
352
+ }
353
+
354
+ if (schema.type === 'boolean') {
355
+ return 'boolean';
356
+ }
357
+
358
+ return 'any';
359
+ }
360
+
361
+ // Extract parameters from OpenAPI operation
362
+ function extractParameters(operation) {
363
+ if (!operation.parameters) return {};
364
+
365
+ const params = {};
366
+ operation.parameters.forEach(param => {
367
+ if (param.in === 'query' || param.in === 'path') {
368
+ const optional = param.required === false ? '?' : '';
369
+ let type = 'string';
370
+
371
+ if (param.schema) {
372
+ if (param.schema.type === 'integer' || param.schema.type === 'number') {
373
+ type = 'number';
374
+ } else if (param.schema.type === 'boolean') {
375
+ type = 'boolean';
376
+ } else if (param.schema.type === 'array') {
377
+ type = 'string[]';
378
+ }
379
+ }
380
+
381
+ params[param.name] = {
382
+ type,
383
+ optional,
384
+ deprecated: param.deprecated || false,
385
+ description: param.description
386
+ };
387
+ }
388
+ });
389
+
390
+ return params;
391
+ }
392
+
393
+ // Extract request body type
394
+ function extractRequestBody(operation) {
395
+ if (!operation.requestBody) return null;
396
+
397
+ const content = operation.requestBody.content;
398
+ if (content && content['application/json']) {
399
+ return generateTypeFromSchema(content['application/json'].schema);
400
+ }
401
+
402
+ return null;
403
+ }
404
+
405
+ // Extract response type
406
+ function extractResponseType(operation) {
407
+ if (!operation.responses) return null;
408
+
409
+ const successResponse = operation.responses['200'] || operation.responses['201'] || operation.responses['204'] || Object.values(operation.responses)[0];
410
+ if (!successResponse) return null;
411
+
412
+ const content = successResponse.content;
413
+ if (content && typeof content === 'object') {
414
+ for (const key of Object.keys(content)) {
415
+ if (key && key.toLowerCase().includes('json') && content[key] && content[key].schema) {
416
+ return generateTypeFromSchema(content[key].schema);
417
+ }
418
+ }
419
+ const firstKey = Object.keys(content)[0];
420
+ if (firstKey && content[firstKey] && content[firstKey].schema) {
421
+ return generateTypeFromSchema(content[firstKey].schema);
422
+ }
423
+ }
424
+
425
+ if (successResponse.schema) {
426
+ return generateTypeFromSchema(successResponse.schema);
427
+ }
428
+
429
+ if (successResponse.description && !content) {
430
+ return 'void';
431
+ }
432
+
433
+ return null;
434
+ }
435
+
436
+ // Prefer per-operation description/summary (often derived from proto comments).
437
+ // When protoc-gen-openapiv2 is too old to include source_info, we still keep whatever
438
+ // description/summary exists in the swagger output.
439
+ function getOperationDoc(op) {
440
+ if (!op) return '';
441
+ const parts = [];
442
+ if (typeof op.summary === 'string' && op.summary.trim()) parts.push(op.summary.trim());
443
+ if (typeof op.description === 'string' && op.description.trim()) parts.push(op.description.trim());
444
+ // De-duplicate if summary is a prefix of description
445
+ if (parts.length === 2 && parts[1].startsWith(parts[0])) {
446
+ return parts[1];
447
+ }
448
+ return parts.join('\n\n');
449
+ }
450
+
451
+ const outDir = path.dirname(outputFile);
452
+ if (!fs.existsSync(outDir)) {
453
+ fs.mkdirSync(outDir, { recursive: true });
454
+ }
455
+
456
+ // Ensure an env wrapper exists alongside generated endpoints so imports resolve.
457
+ const envWrapperPath = path.join(outDir, 'envWrapper.ts');
458
+ if (!fs.existsSync(envWrapperPath)) {
459
+ fs.writeFileSync(envWrapperPath, `let backendApiDomain = ""
460
+
461
+ export function getBackendApiDomain(): string {
462
+ if (!backendApiDomain) {
463
+ throw new Error("Backend API domain is not set. Please call setBackendApiDomain first.")
464
+ }
465
+ return backendApiDomain
466
+ }
467
+
468
+ export function setBackendApiDomain(domain: string): void {
469
+ if (backendApiDomain) {
470
+ throw new Error("Backend API domain is already set.")
471
+ }
472
+ backendApiDomain = domain
473
+ }`);
474
+ }
475
+
476
+ // Generate endpoints mapping file
477
+ const endpoints = {};
478
+ const pathsObj = openapiSpec.paths || {};
479
+ Object.entries(pathsObj).forEach(([p, methods]) => {
480
+ Object.entries(methods).forEach(([m, operation]) => {
481
+ if (!operation || !operation.operationId) return;
482
+ let opId = operation.operationId;
483
+ const usIdx = opId.indexOf('_');
484
+ const dIdx = opId.indexOf('.');
485
+ if (usIdx !== -1) {
486
+ opId = opId.substring(usIdx + 1);
487
+ } else if (dIdx !== -1) {
488
+ opId = opId.substring(dIdx + 1);
489
+ }
490
+ const rpcName = toPascalCase(opId);
491
+
492
+ const paramsObj = extractParameters(operation);
493
+ const requestBodyType = extractRequestBody(operation);
494
+ const responseType = extractResponseType(operation) || 'void';
495
+
496
+ function findMessageByCandidates(candidates) {
497
+ for (const cand of candidates) {
498
+ for (const mt of messageTypes) {
499
+ if (mt.toLowerCase() === cand.toLowerCase()) return mt;
500
+ }
501
+ }
502
+ return null;
503
+ }
504
+
505
+ let inferredResponseType = responseType;
506
+ if (!inferredResponseType || inferredResponseType === 'any' || /^\s*\{\s*\}?$/m.test(String(inferredResponseType))) {
507
+ const respCandidates = [
508
+ `${rpcName}Response`, `${rpcName}Reply`, `${rpcName}Res`, `${rpcName}Result`, `${rpcName}Output`, `${rpcName}Dto`,
509
+ `${rpcName}ResponseMessage`, `${rpcName}ResponseDto`
510
+ ];
511
+ const foundResp = findMessageByCandidates(respCandidates);
512
+ if (foundResp) {
513
+ inferredResponseType = `Messages.${foundResp}`;
514
+ }
515
+ }
516
+
517
+ let inferredRequestBodyType = requestBodyType;
518
+ if (!inferredRequestBodyType || inferredRequestBodyType === 'any') {
519
+ const reqCandidates = [
520
+ `${rpcName}Request`, `${rpcName}Req`, `${rpcName}Params`, `${rpcName}Input`, `${rpcName}Body`,
521
+ `${rpcName}RequestMessage`, `${rpcName}RequestDto`
522
+ ];
523
+ const foundReq = findMessageByCandidates(reqCandidates);
524
+ if (foundReq) {
525
+ inferredRequestBodyType = `Messages.${foundReq}`;
526
+ }
527
+ }
528
+
529
+ let inputTypeStr = 'void';
530
+ if (inferredRequestBodyType) {
531
+ inputTypeStr = inferredRequestBodyType;
532
+ } else if (Object.keys(paramsObj).length > 0) {
533
+ const parts = Object.entries(paramsObj).map(([name, pinfo]) => `${name}${pinfo.optional}: ${pinfo.type}`);
534
+ inputTypeStr = `{ ${parts.join(', ')} }`;
535
+ }
536
+
537
+ const protoEntry = protoRpcMap[rpcName];
538
+ let protoInputName = null;
539
+ let protoOutputName = null;
540
+ if (protoEntry) {
541
+ if (protoEntry.request) protoInputName = String(protoEntry.request).split('.').pop();
542
+ if (protoEntry.response) protoOutputName = String(protoEntry.response).split('.').pop();
543
+ }
544
+
545
+ function displayTypeName(t) {
546
+ if (!t) return 'void';
547
+ if (t === 'Empty') return '{}';
548
+ if (typeof t === 'string' && t.startsWith('Messages.')) return t.split('.')[1] || t;
549
+ return t;
550
+ }
551
+
552
+ const chosenInput = protoInputName || inputTypeStr;
553
+ const chosenOutput = protoOutputName || inferredResponseType || 'void';
554
+
555
+ const protoDoc = protoRpcDocsMap[rpcName] || '';
556
+ const fallbackDoc = (operation.description || operation.summary || '').trim();
557
+ const doc = (protoDoc || fallbackDoc).trim();
558
+
559
+ const operationDoc = getOperationDoc(operation);
560
+
561
+ const info = {
562
+ path: p,
563
+ method: (m || '').toUpperCase(),
564
+ input: displayTypeName(chosenInput),
565
+ output: displayTypeName(chosenOutput),
566
+ deprecated: !!operation.deprecated,
567
+ summary: operation.summary || '',
568
+ description: doc,
569
+ doc: doc
570
+ };
571
+
572
+ const lines = [];
573
+ if (operationDoc) {
574
+ lines.push('/**');
575
+ for (const line of operationDoc.split('\n')) {
576
+ lines.push(` * ${line}`.trimEnd());
577
+ }
578
+ lines.push(' */');
579
+ }
580
+
581
+ if (!endpoints[rpcName]) {
582
+ endpoints[rpcName] = info;
583
+ }
584
+ });
585
+ });
586
+
587
+ let serviceObjectName = toPascalCase(serviceName).replace(/-/g, '');
588
+ if (!serviceObjectName.endsWith('Service')) {
589
+ serviceObjectName = `${serviceObjectName}Service`;
590
+ }
591
+
592
+ // Build method strings and track all referenced types
593
+ const methodLines = [];
594
+ const allReferencedTypes = new Set();
595
+
596
+ Object.entries(endpoints).forEach(([k, info]) => {
597
+ // Track input and output types for imports
598
+ if (info.input && !isSkippableImportType(info.input) && /^[A-Za-z_][A-Za-z0-9_]*$/.test(info.input)) {
599
+ allReferencedTypes.add(info.input);
600
+ }
601
+ if (info.output && !isSkippableImportType(info.output) && /^[A-Za-z_][A-ZaZ0-9_]*$/.test(info.output)) {
602
+ allReferencedTypes.add(info.output);
603
+ }
604
+
605
+ let methodStr = '/**\n';
606
+ if (info.doc) {
607
+ const docLines = String(info.doc).split(/\r?\n/);
608
+ for (const dl of docLines) {
609
+ methodStr += ` * ${dl}\n`;
610
+ }
611
+ methodStr += ' *\n';
612
+ }
613
+ methodStr += ` * @method ${info.method}\n`;
614
+ methodStr += ` * @input ${info.input}\n`;
615
+ methodStr += ` * @output ${info.output}\n`;
616
+ methodStr += ` */\n`;
617
+
618
+ const urlPath = info.path || '';
619
+ const params = [];
620
+ const paramRegex = /\{([^}]+)\}/g;
621
+ let match;
622
+ while ((match = paramRegex.exec(urlPath)) !== null) {
623
+ params.push(match[1]);
624
+ }
625
+
626
+ // For GET endpoints, include fields from proto input message as optional query params
627
+ // Only uses messages from current service (no cross-service pollution)
628
+ const extraParams = [];
629
+ if (info.method === 'GET' && info.input && !isNoInputType(info.input)) {
630
+ const msgName = info.input;
631
+ const protoFields = getProtoFieldsForMessage(msgName);
632
+ if (msgName && protoFields) {
633
+ for (const field of protoFields) {
634
+ const camel = toCamelCase(field.name);
635
+ const snake = field.name;
636
+ if (params.includes(camel) || params.includes(snake)) continue;
637
+ extraParams.push(`${camel}?: ${field.tsType}`);
638
+ }
639
+ }
640
+ }
641
+
642
+ const paramsSignatureParts = [];
643
+ for (const p of params) {
644
+ const paramName = toCamelCase(p);
645
+ paramsSignatureParts.push(`${paramName}: string`);
646
+ }
647
+ for (const ep of extraParams) paramsSignatureParts.push(ep);
648
+ const paramsSignature = paramsSignatureParts.join(', ');
649
+
650
+ const templateForOutput = urlPath.replace(/\{([^}]+)\}/g, (_, p) => '${' + toCamelCase(p) + '}');
651
+ const extraParamNames = extraParams.map(ep => ep.split('?')[0].split(':')[0].trim());
652
+
653
+ // If this is a GET and we have a named input type, emit a single destructured param typed as that request
654
+ if (info.method === 'GET' && info.input && !isNoInputType(info.input)) {
655
+ const pathParamNames = params.map(p => toCamelCase(p));
656
+ const allDestructureNames = Array.from(new Set([...pathParamNames, ...extraParamNames]));
657
+ const destructureList = allDestructureNames.join(', ');
658
+ const typeName = info.input;
659
+ if (typeName && !isSkippableImportType(typeName) && /^[A-Za-z_][A-ZaZ0-9_]*$/.test(typeName)) allReferencedTypes.add(typeName);
660
+
661
+ const queryTemplate = extraParamNames.length > 0 ? '?' + extraParamNames.map(n => n + '=${' + n + '}').join('&') : '';
662
+ const methodParts = [
663
+ ' ',
664
+ k,
665
+ '({ ',
666
+ destructureList,
667
+ ' }: ',
668
+ typeName,
669
+ '): string { ',
670
+ prefixedReturn('`' + templateForOutput + queryTemplate + '`'),
671
+ ' }'
672
+ ];
673
+ methodStr += methodParts.join('');
674
+ methodLines.push(methodStr);
675
+ return;
676
+ }
677
+
678
+ // If there are no params and this endpoint has no meaningful input, emit a no-arg method.
679
+ if (paramsSignatureParts.length === 0) {
680
+ methodStr += ` ${k}(): string { ` + prefixedReturn("'" + urlPath + "'") + ` }`;
681
+ methodLines.push(methodStr);
682
+ return;
683
+ }
684
+
685
+ // Non-destructured emission
686
+ if (extraParamNames.length > 0) {
687
+ const queryTemplate = '?' + extraParamNames.map(n => n + '=${' + n + '}').join('&');
688
+ const methodParts = [' ', k, '(', paramsSignature, '): string { ', prefixedReturn('`' + templateForOutput + queryTemplate + '`'), ' }'];
689
+ methodStr += methodParts.join('');
690
+ methodLines.push(methodStr);
691
+ } else {
692
+ methodStr += ` ${k}(${paramsSignature}): string { ` + prefixedReturn('`' + templateForOutput + '`') + ` }`;
693
+ methodLines.push(methodStr);
694
+ }
695
+ });
696
+
697
+ // Prefix returned URL strings with the configured base URL.
698
+ // Input (expr) must be a JS/TS string expression (e.g. "'/v1/x'" or "`/v1/${id}`").
699
+ function prefixedReturn(expr) {
700
+ return `return getBackendApiDomain() + ${expr};`;
701
+ }
702
+
703
+ // Build import list
704
+ const importTypes = Array.from(allReferencedTypes)
705
+ .filter(t => !isSkippableImportType(t))
706
+ .filter(t => /^[A-Za-z_][A-Za-z0-9_]*$/.test(String(t)))
707
+ .sort();
708
+
709
+ // Determine the correct message file to import from based on service name
710
+ const normalizedSvc = (serviceName || '').replace(/service$/i, '');
711
+ const messageFile = normalizedSvc; // e.g., "post" for post-service
712
+
713
+ let endpointsOutput = '';
714
+
715
+ endpointsOutput += `import { getBackendApiDomain } from "./envWrapper";\n`;
716
+
717
+ // Add imports if there are types to import
718
+ if (importTypes.length > 0) {
719
+ endpointsOutput += `import type { ${importTypes.join(', ')} } from './messages/${messageFile}';\n`;
720
+ // Re-export all imported types for convenience
721
+ endpointsOutput += `export type { ${importTypes.join(', ')} } from './messages/${messageFile}';\n\n`;
722
+ } else {
723
+ endpointsOutput += `\n`;
724
+ }
725
+
726
+ endpointsOutput += `// Auto-generated endpoint URLs for ${serviceObjectName} service\n\n`;
727
+ endpointsOutput += `export const ${serviceObjectName} = {\n`;
728
+ endpointsOutput += methodLines.join(',\n\n');
729
+ endpointsOutput += `,\n};\n`;
730
+
731
+ const endpointsOutFile = path.join(path.dirname(outputFile), `${serviceName.replace("_", "-")}-endpoints.ts`);
732
+ fs.writeFileSync(endpointsOutFile, endpointsOutput);
733
+ console.log(`Generated endpoints for ${serviceObjectName}: ${Object.keys(endpoints).length} entries`);
734
+
735
+ // Some inferred "types" are not real TS type exports and must never be imported.
736
+ function isSkippableImportType(t) {
737
+ if (!t) return true;
738
+ const s = String(t).trim();
739
+ return s === 'void' || s === '{}' || s === 'Empty' || s === 'any';
740
+ }
741
+
742
+ // True when an endpoint has no meaningful input payload for clients.
743
+ function isNoInputType(t) {
744
+ if (!t) return true;
745
+ const s = String(t).trim();
746
+ return s === 'void' || s === '{}' || s === 'Empty';
747
+ }