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.
- package/eslint.config.mjs +15 -0
- package/generate-index.js +159 -0
- package/generate-typescript-types.sh +508 -0
- package/mock_proto_types/empty.ts +12 -0
- package/mock_proto_types/struct.ts +17 -0
- package/mock_proto_types/timestamp.ts +12 -0
- package/mock_proto_types/wrappers.ts +51 -0
- package/package.json +12 -0
- package/process-openapi-types.js +747 -0
- package/typegen-package.json +20 -0
|
@@ -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
|
+
}
|