inviton-backduck 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/LICENSE +21 -0
- package/README.md +302 -0
- package/dist/apidoc/api-doc-generator.d.ts +58 -0
- package/dist/apidoc/api-doc-generator.d.ts.map +1 -0
- package/dist/apidoc/api-doc-generator.js +201 -0
- package/dist/apidoc/api-doc-generator.js.map +1 -0
- package/dist/apidoc/config.d.ts +153 -0
- package/dist/apidoc/config.d.ts.map +1 -0
- package/dist/apidoc/config.js +254 -0
- package/dist/apidoc/config.js.map +1 -0
- package/dist/apidoc/controller-parser.d.ts +208 -0
- package/dist/apidoc/controller-parser.d.ts.map +1 -0
- package/dist/apidoc/controller-parser.js +686 -0
- package/dist/apidoc/controller-parser.js.map +1 -0
- package/dist/apidoc/html-generator.d.ts +290 -0
- package/dist/apidoc/html-generator.d.ts.map +1 -0
- package/dist/apidoc/html-generator.js +2295 -0
- package/dist/apidoc/html-generator.js.map +1 -0
- package/dist/apidoc/index.d.ts +20 -0
- package/dist/apidoc/index.d.ts.map +1 -0
- package/dist/apidoc/index.js +16 -0
- package/dist/apidoc/index.js.map +1 -0
- package/dist/apidoc/openapi-builder.d.ts +169 -0
- package/dist/apidoc/openapi-builder.d.ts.map +1 -0
- package/dist/apidoc/openapi-builder.js +634 -0
- package/dist/apidoc/openapi-builder.js.map +1 -0
- package/dist/apidoc/parameterGeneratorRegistry.d.ts +20 -0
- package/dist/apidoc/parameterGeneratorRegistry.d.ts.map +1 -0
- package/dist/apidoc/parameterGeneratorRegistry.js +6 -0
- package/dist/apidoc/parameterGeneratorRegistry.js.map +1 -0
- package/dist/apidoc/test-type-resolver.d.ts +2 -0
- package/dist/apidoc/test-type-resolver.d.ts.map +1 -0
- package/dist/apidoc/test-type-resolver.js +6 -0
- package/dist/apidoc/test-type-resolver.js.map +1 -0
- package/dist/apidoc/type-resolver.d.ts +266 -0
- package/dist/apidoc/type-resolver.d.ts.map +1 -0
- package/dist/apidoc/type-resolver.js +1226 -0
- package/dist/apidoc/type-resolver.js.map +1 -0
- package/dist/apidoc/verify-type-resolution.d.ts +3 -0
- package/dist/apidoc/verify-type-resolution.d.ts.map +1 -0
- package/dist/apidoc/verify-type-resolution.js +29 -0
- package/dist/apidoc/verify-type-resolution.js.map +1 -0
- package/dist/bun/bunRouter.d.ts +70 -0
- package/dist/bun/bunRouter.d.ts.map +1 -0
- package/dist/bun/bunRouter.js +324 -0
- package/dist/bun/bunRouter.js.map +1 -0
- package/dist/bun/bunServer.d.ts +72 -0
- package/dist/bun/bunServer.d.ts.map +1 -0
- package/dist/bun/bunServer.js +218 -0
- package/dist/bun/bunServer.js.map +1 -0
- package/dist/bun/bunStaticFiles.d.ts +76 -0
- package/dist/bun/bunStaticFiles.d.ts.map +1 -0
- package/dist/bun/bunStaticFiles.js +251 -0
- package/dist/bun/bunStaticFiles.js.map +1 -0
- package/dist/bun/index.d.ts +7 -0
- package/dist/bun/index.d.ts.map +1 -0
- package/dist/bun/index.js +7 -0
- package/dist/bun/index.js.map +1 -0
- package/dist/data-contracts.d.ts +132 -0
- package/dist/data-contracts.d.ts.map +1 -0
- package/dist/data-contracts.js +2 -0
- package/dist/data-contracts.js.map +1 -0
- package/dist/decorators.d.ts +75 -0
- package/dist/decorators.d.ts.map +1 -0
- package/dist/decorators.js +101 -0
- package/dist/decorators.js.map +1 -0
- package/dist/express/expressFrontendRouter.d.ts +17 -0
- package/dist/express/expressFrontendRouter.d.ts.map +1 -0
- package/dist/express/expressFrontendRouter.js +33 -0
- package/dist/express/expressFrontendRouter.js.map +1 -0
- package/dist/express/expressRouter.d.ts +25 -0
- package/dist/express/expressRouter.d.ts.map +1 -0
- package/dist/express/expressRouter.js +150 -0
- package/dist/express/expressRouter.js.map +1 -0
- package/dist/express/index.d.ts +6 -0
- package/dist/express/index.d.ts.map +1 -0
- package/dist/express/index.js +6 -0
- package/dist/express/index.js.map +1 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +52 -0
- package/dist/index.js.map +1 -0
- package/dist/router.d.ts +162 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +350 -0
- package/dist/router.js.map +1 -0
- package/dist/runtime-detect.d.ts +20 -0
- package/dist/runtime-detect.d.ts.map +1 -0
- package/dist/runtime-detect.js +20 -0
- package/dist/runtime-detect.js.map +1 -0
- package/dist/server.d.ts +126 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +181 -0
- package/dist/server.js.map +1 -0
- package/dist/utils.d.ts +83 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +157 -0
- package/dist/utils.js.map +1 -0
- package/package.json +65 -0
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { Node, Project, SyntaxKind } from 'ts-morph';
|
|
4
|
+
import { ApiDocConfig } from './config';
|
|
5
|
+
/**
|
|
6
|
+
* Parser for TypeScript controller files using ts-morph
|
|
7
|
+
*/
|
|
8
|
+
export class ControllerParser {
|
|
9
|
+
project;
|
|
10
|
+
serverDir;
|
|
11
|
+
routerCache = new Map();
|
|
12
|
+
controllerPathCache = new Map();
|
|
13
|
+
typeResolver = null;
|
|
14
|
+
constructor(serverDir) {
|
|
15
|
+
this.serverDir = path.resolve(serverDir);
|
|
16
|
+
this.project = new Project({
|
|
17
|
+
tsConfigFilePath: path.join(this.serverDir, 'tsconfig.json'),
|
|
18
|
+
skipAddingFilesFromTsConfig: true,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Add a source file to the project by file path
|
|
23
|
+
* @param filePath The path to the TS source file
|
|
24
|
+
* @returns The added SourceFile object
|
|
25
|
+
*/
|
|
26
|
+
addSourceFileAtPath(filePath) {
|
|
27
|
+
return this.project.addSourceFileAtPath(filePath);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Set the TypeResolver instance for extracting service method JSDoc
|
|
31
|
+
*/
|
|
32
|
+
setTypeResolver(typeResolver) {
|
|
33
|
+
this.typeResolver = typeResolver;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Parse endpoint info by controller class name
|
|
37
|
+
*/
|
|
38
|
+
parseEndpoint(controllerClassName, apiType = 'shopApi') {
|
|
39
|
+
// Build controller path cache if not already built
|
|
40
|
+
if (this.controllerPathCache.size === 0) {
|
|
41
|
+
this.buildControllerPathCache(apiType);
|
|
42
|
+
}
|
|
43
|
+
const cached = this.controllerPathCache.get(controllerClassName);
|
|
44
|
+
if (!cached) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
// Parse the controller to get route path
|
|
48
|
+
const controllerInfo = this.parseController(cached.filePath);
|
|
49
|
+
if (!controllerInfo) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
className: controllerClassName,
|
|
54
|
+
routePath: controllerInfo.routePath,
|
|
55
|
+
fullPath: cached.fullPath,
|
|
56
|
+
filePath: cached.filePath,
|
|
57
|
+
tag: cached.tag,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Build cache of controller -> full path mappings by traversing router hierarchy
|
|
62
|
+
*/
|
|
63
|
+
buildControllerPathCache(apiType) {
|
|
64
|
+
const sourceDir = ApiDocConfig.getSourcePath(this.serverDir, apiType);
|
|
65
|
+
const basePath = apiType === 'shopApi' ? ApiDocConfig.basePaths.shop : ApiDocConfig.basePaths.admin;
|
|
66
|
+
const mainRouterPath = path.join(sourceDir, '_router.ts');
|
|
67
|
+
if (!fs.existsSync(mainRouterPath)) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
this.traverseRouterHierarchy(mainRouterPath, basePath, '');
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Recursively traverse router hierarchy and build controller path mappings
|
|
74
|
+
*/
|
|
75
|
+
traverseRouterHierarchy(routerFilePath, pathPrefix, tag) {
|
|
76
|
+
const routerInfo = this.parseRouterFile(routerFilePath);
|
|
77
|
+
if (!routerInfo) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// Process registered controllers
|
|
81
|
+
for (const controller of routerInfo.controllers) {
|
|
82
|
+
const controllerFilePath = this.resolveImportPath(routerFilePath, controller.importPath);
|
|
83
|
+
if (!controllerFilePath || !fs.existsSync(controllerFilePath)) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
// Parse controller to get @Route path
|
|
87
|
+
const controllerInfo = this.parseController(controllerFilePath);
|
|
88
|
+
if (controllerInfo) {
|
|
89
|
+
const fullPath = this.normalizePath(pathPrefix + controllerInfo.routePath);
|
|
90
|
+
this.controllerPathCache.set(controller.controllerClassName, {
|
|
91
|
+
fullPath,
|
|
92
|
+
filePath: controllerFilePath,
|
|
93
|
+
tag: tag || controllerInfo.tag,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Process sub-routers
|
|
98
|
+
for (const subRouter of routerInfo.subRouters) {
|
|
99
|
+
const subRouterFilePath = this.resolveImportPath(routerFilePath, subRouter.importPath);
|
|
100
|
+
if (!subRouterFilePath || !fs.existsSync(subRouterFilePath)) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const newPathPrefix = pathPrefix + subRouter.path;
|
|
104
|
+
const newTag = tag || subRouter.path.replace(/^\//, '');
|
|
105
|
+
this.traverseRouterHierarchy(subRouterFilePath, newPathPrefix, newTag);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Parse a _router.ts file to extract router.use() and router.registerController() calls
|
|
110
|
+
*/
|
|
111
|
+
parseRouterFile(filePath) {
|
|
112
|
+
const cached = this.routerCache.get(filePath);
|
|
113
|
+
if (cached) {
|
|
114
|
+
return cached;
|
|
115
|
+
}
|
|
116
|
+
if (!fs.existsSync(filePath)) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const sourceFile = this.project.addSourceFileAtPath(filePath);
|
|
120
|
+
const imports = this.parseImports(sourceFile);
|
|
121
|
+
const subRouters = [];
|
|
122
|
+
const controllers = [];
|
|
123
|
+
// Find all call expressions
|
|
124
|
+
const callExpressions = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
|
|
125
|
+
for (const callExpr of callExpressions) {
|
|
126
|
+
const expression = callExpr.getExpression();
|
|
127
|
+
if (!Node.isPropertyAccessExpression(expression)) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const methodName = expression.getName();
|
|
131
|
+
const args = callExpr.getArguments();
|
|
132
|
+
if (methodName === 'use' && args.length >= 2) {
|
|
133
|
+
// router.use('/path', subRouter)
|
|
134
|
+
const pathArg = args[0];
|
|
135
|
+
const routerArg = args[1];
|
|
136
|
+
if (Node.isStringLiteral(pathArg)) {
|
|
137
|
+
const routePath = pathArg.getLiteralValue();
|
|
138
|
+
const routerVarName = routerArg.getText();
|
|
139
|
+
const importPath = imports.get(routerVarName);
|
|
140
|
+
if (importPath) {
|
|
141
|
+
subRouters.push({
|
|
142
|
+
path: routePath,
|
|
143
|
+
routerVariableName: routerVarName,
|
|
144
|
+
importPath,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
else if (methodName === 'registerController' && args.length >= 1) {
|
|
150
|
+
// router.registerController(SomeController)
|
|
151
|
+
const controllerArg = args[0];
|
|
152
|
+
const controllerName = controllerArg.getText();
|
|
153
|
+
const importPath = imports.get(controllerName);
|
|
154
|
+
if (importPath) {
|
|
155
|
+
controllers.push({
|
|
156
|
+
controllerClassName: controllerName,
|
|
157
|
+
importPath,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const relativePath = path.relative(this.serverDir, filePath).replace(/\\/g, '/');
|
|
163
|
+
const directory = path.dirname(filePath);
|
|
164
|
+
const routerInfo = {
|
|
165
|
+
filePath,
|
|
166
|
+
relativePath,
|
|
167
|
+
directory,
|
|
168
|
+
subRouters,
|
|
169
|
+
controllers,
|
|
170
|
+
};
|
|
171
|
+
this.routerCache.set(filePath, routerInfo);
|
|
172
|
+
return routerInfo;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Parse import statements from a source file
|
|
176
|
+
*/
|
|
177
|
+
parseImports(sourceFile) {
|
|
178
|
+
const imports = new Map();
|
|
179
|
+
const importDeclarations = sourceFile.getImportDeclarations();
|
|
180
|
+
for (const importDecl of importDeclarations) {
|
|
181
|
+
const moduleSpecifier = importDecl.getModuleSpecifierValue();
|
|
182
|
+
const defaultImport = importDecl.getDefaultImport();
|
|
183
|
+
if (defaultImport) {
|
|
184
|
+
imports.set(defaultImport.getText(), moduleSpecifier);
|
|
185
|
+
}
|
|
186
|
+
const namedImports = importDecl.getNamedImports();
|
|
187
|
+
for (const namedImport of namedImports) {
|
|
188
|
+
const name = namedImport.getAliasNode()?.getText() || namedImport.getName();
|
|
189
|
+
imports.set(name, moduleSpecifier);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return imports;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Resolve a relative import path to an absolute file path
|
|
196
|
+
*/
|
|
197
|
+
resolveImportPath(fromFile, importPath) {
|
|
198
|
+
if (!importPath.startsWith('.')) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
const fromDir = path.dirname(fromFile);
|
|
202
|
+
const resolvedPath = path.resolve(fromDir, importPath);
|
|
203
|
+
// Try with .ts extension
|
|
204
|
+
if (fs.existsSync(`${resolvedPath}.ts`)) {
|
|
205
|
+
return `${resolvedPath}.ts`;
|
|
206
|
+
}
|
|
207
|
+
// Try as directory with index.ts
|
|
208
|
+
if (fs.existsSync(path.join(resolvedPath, 'index.ts'))) {
|
|
209
|
+
return path.join(resolvedPath, 'index.ts');
|
|
210
|
+
}
|
|
211
|
+
// Try as directory with _router.ts (for sub-router imports)
|
|
212
|
+
if (fs.existsSync(path.join(resolvedPath, '_router.ts'))) {
|
|
213
|
+
return path.join(resolvedPath, '_router.ts');
|
|
214
|
+
}
|
|
215
|
+
// Maybe it's already a complete path
|
|
216
|
+
if (fs.existsSync(resolvedPath)) {
|
|
217
|
+
return resolvedPath;
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Normalize path by removing duplicate slashes and ensuring single leading slash
|
|
223
|
+
*/
|
|
224
|
+
normalizePath(p) {
|
|
225
|
+
return (`/${p}`).replace(/\/+/g, '/').replace(/\/$/, '') || '/';
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Discover all controller files in the shop API directory
|
|
229
|
+
*/
|
|
230
|
+
discoverControllers(apiType = 'shopApi') {
|
|
231
|
+
const sourceDir = ApiDocConfig.getSourcePath(this.serverDir, apiType);
|
|
232
|
+
const pattern = ApiDocConfig.patterns.controllerFiles;
|
|
233
|
+
return this.findFilesRecursive(sourceDir, pattern);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Parse a single controller file
|
|
237
|
+
*/
|
|
238
|
+
parseController(filePath) {
|
|
239
|
+
// Try to get already-loaded source file first, otherwise add it
|
|
240
|
+
let sourceFile = this.project.getSourceFile(filePath);
|
|
241
|
+
if (!sourceFile) {
|
|
242
|
+
sourceFile = this.project.addSourceFileAtPath(filePath);
|
|
243
|
+
}
|
|
244
|
+
const classes = sourceFile.getClasses();
|
|
245
|
+
const controllerClass = classes.find(cls => cls.getExtends()?.getText()?.includes('ControllerBase')
|
|
246
|
+
|| cls.getName()?.includes('Controller'));
|
|
247
|
+
if (!controllerClass) {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
const className = controllerClass.getName() || 'UnknownController';
|
|
251
|
+
const routePath = this.extractRouteDecorator(controllerClass);
|
|
252
|
+
const methods = this.extractMethods(controllerClass);
|
|
253
|
+
const relativePath = path.relative(this.serverDir, filePath).replace(/\\/g, '/');
|
|
254
|
+
const tag = this.extractTagFromPath(relativePath);
|
|
255
|
+
return {
|
|
256
|
+
className,
|
|
257
|
+
routePath: routePath || this.inferRouteFromClassName(className),
|
|
258
|
+
filePath,
|
|
259
|
+
relativePath,
|
|
260
|
+
methods,
|
|
261
|
+
tag,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Parse all controllers in the shop API
|
|
266
|
+
*/
|
|
267
|
+
parseAllControllers(apiType = 'shopApi') {
|
|
268
|
+
/* eslint-disable no-console */
|
|
269
|
+
console.time('🔍 Discovering controller files');
|
|
270
|
+
const files = this.discoverControllers(apiType);
|
|
271
|
+
console.timeEnd('🔍 Discovering controller files');
|
|
272
|
+
console.log(`📁 Found ${files.length} controller files`);
|
|
273
|
+
console.time('📦 Adding all source files to ts-morph project');
|
|
274
|
+
// Pre-load all source files into the project for faster parsing
|
|
275
|
+
for (const file of files) {
|
|
276
|
+
this.project.addSourceFileAtPath(file);
|
|
277
|
+
}
|
|
278
|
+
console.timeEnd('📦 Adding all source files to ts-morph project');
|
|
279
|
+
console.time('🔨 Parsing all controllers');
|
|
280
|
+
const controllers = [];
|
|
281
|
+
for (let i = 0; i < files.length; i++) {
|
|
282
|
+
const file = files[i];
|
|
283
|
+
const fileName = path.basename(file);
|
|
284
|
+
const startTime = performance.now();
|
|
285
|
+
const info = this.parseController(file);
|
|
286
|
+
const duration = performance.now() - startTime;
|
|
287
|
+
if (duration > 100) {
|
|
288
|
+
console.log(` ⏱️ ${fileName} took ${duration.toFixed(0)}ms`);
|
|
289
|
+
}
|
|
290
|
+
if (info && info.methods.length > 0) {
|
|
291
|
+
controllers.push(info);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
console.timeEnd('🔨 Parsing all controllers');
|
|
295
|
+
console.log(`✅ Parsed ${controllers.length} controllers with endpoints`);
|
|
296
|
+
/* eslint-enable no-console */
|
|
297
|
+
return controllers;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Extract the route path from @Route decorator
|
|
301
|
+
*/
|
|
302
|
+
extractRouteDecorator(classDecl) {
|
|
303
|
+
const decorators = classDecl.getDecorators();
|
|
304
|
+
for (const decorator of decorators) {
|
|
305
|
+
const name = decorator.getName();
|
|
306
|
+
if (name === ApiDocConfig.decorators.route) {
|
|
307
|
+
return this.extractDecoratorStringArg(decorator);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Extract methods from a controller class
|
|
314
|
+
*/
|
|
315
|
+
extractMethods(classDecl) {
|
|
316
|
+
const methods = [];
|
|
317
|
+
const classMethods = classDecl.getMethods();
|
|
318
|
+
const sourceFile = classDecl.getSourceFile();
|
|
319
|
+
for (const method of classMethods) {
|
|
320
|
+
const methodName = method.getName();
|
|
321
|
+
const httpMethod = ApiDocConfig.getHttpMethod(methodName);
|
|
322
|
+
if (!httpMethod) {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const auth = this.extractAuthDecorator(method);
|
|
326
|
+
const { requestType, responseType } = this.extractMethodTypes(method);
|
|
327
|
+
const description = this.extractJsDocDescription(method);
|
|
328
|
+
const paramDescription = this.extractJsDocParams(method);
|
|
329
|
+
// Extract service description and extra parameters from service interface if available
|
|
330
|
+
let serviceDescription = null;
|
|
331
|
+
let extraParameterGenerator = null;
|
|
332
|
+
if (this.typeResolver) {
|
|
333
|
+
const serviceMapping = this.extractServiceMethodMapping(method, sourceFile);
|
|
334
|
+
if (serviceMapping) {
|
|
335
|
+
serviceDescription = this.typeResolver.extractServiceMethodJsDoc(serviceMapping.interfaceType, serviceMapping.methodName);
|
|
336
|
+
extraParameterGenerator = this.typeResolver.extractServiceMethodExtraParameters(serviceMapping.interfaceType, serviceMapping.methodName);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
methods.push({
|
|
340
|
+
name: methodName,
|
|
341
|
+
httpMethod,
|
|
342
|
+
auth,
|
|
343
|
+
requestType,
|
|
344
|
+
responseType,
|
|
345
|
+
description,
|
|
346
|
+
serviceDescription,
|
|
347
|
+
paramDescription,
|
|
348
|
+
extraParameterGenerator,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
return methods;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Extract authentication decorator information
|
|
355
|
+
*/
|
|
356
|
+
extractAuthDecorator(method) {
|
|
357
|
+
const decorators = method.getDecorators();
|
|
358
|
+
const result = {
|
|
359
|
+
hasAuth: false,
|
|
360
|
+
requiresLogin: false,
|
|
361
|
+
requiresVerifiedAccount: false,
|
|
362
|
+
isEnterpriseOnly: false,
|
|
363
|
+
};
|
|
364
|
+
for (const decorator of decorators) {
|
|
365
|
+
const name = decorator.getName();
|
|
366
|
+
// Check for enterprise auth decorator
|
|
367
|
+
if (name === ApiDocConfig.decorators.shopEnterpriseAuth
|
|
368
|
+
|| name === 'ShopApiEnterpriseAuthRequired'
|
|
369
|
+
|| name === 'ShopApiEnterpriseAuthRequiresVerifiedAccount') {
|
|
370
|
+
result.hasAuth = true;
|
|
371
|
+
result.isEnterpriseOnly = true;
|
|
372
|
+
result.requiresLogin = true; // Enterprise auth always requires login (via API key)
|
|
373
|
+
if (name === 'ShopApiEnterpriseAuthRequiresVerifiedAccount') {
|
|
374
|
+
result.requiresVerifiedAccount = true;
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
const args = this.extractDecoratorObjectArg(decorator);
|
|
378
|
+
if (args) {
|
|
379
|
+
result.requiresVerifiedAccount = args.requiresVerifiedAccount === true;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
// Check for regular shop auth decorator
|
|
385
|
+
if (name === ApiDocConfig.decorators.shopAuth || name === 'ShopApiAuthRequiresLogin' || name === 'ShopApiAuthRequiresVerifiedAccount') {
|
|
386
|
+
result.hasAuth = true;
|
|
387
|
+
if (name === 'ShopApiAuthRequiresLogin') {
|
|
388
|
+
result.requiresLogin = true;
|
|
389
|
+
}
|
|
390
|
+
else if (name === 'ShopApiAuthRequiresVerifiedAccount') {
|
|
391
|
+
result.requiresLogin = true;
|
|
392
|
+
result.requiresVerifiedAccount = true;
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
const args = this.extractDecoratorObjectArg(decorator);
|
|
396
|
+
if (args) {
|
|
397
|
+
result.requiresLogin = args.requiresLogin === true;
|
|
398
|
+
result.requiresVerifiedAccount = args.requiresVerifiedAccount === true;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return result;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Extract request and response types from method signature
|
|
408
|
+
*/
|
|
409
|
+
extractMethodTypes(method) {
|
|
410
|
+
let requestType = null;
|
|
411
|
+
let responseType = null;
|
|
412
|
+
// Get request type from first parameter
|
|
413
|
+
const params = method.getParameters();
|
|
414
|
+
if (params.length > 0) {
|
|
415
|
+
const firstParam = params[0];
|
|
416
|
+
const paramType = firstParam.getType();
|
|
417
|
+
requestType = this.cleanTypeName(paramType.getText());
|
|
418
|
+
}
|
|
419
|
+
// Get response type from return type
|
|
420
|
+
const returnType = method.getReturnType();
|
|
421
|
+
const returnTypeText = returnType.getText();
|
|
422
|
+
// Handle Promise<T>
|
|
423
|
+
const promiseMatch = returnTypeText.match(/Promise<(.+)>/);
|
|
424
|
+
if (promiseMatch) {
|
|
425
|
+
responseType = this.cleanTypeName(promiseMatch[1]);
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
responseType = this.cleanTypeName(returnTypeText);
|
|
429
|
+
}
|
|
430
|
+
return { requestType, responseType };
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Extract JSDoc description from a method
|
|
434
|
+
*/
|
|
435
|
+
extractJsDocDescription(method) {
|
|
436
|
+
const jsDocs = method.getJsDocs();
|
|
437
|
+
if (jsDocs.length > 0) {
|
|
438
|
+
// Preserve newlines and indentation for complex descriptions with code blocks
|
|
439
|
+
const description = jsDocs[0].getDescription();
|
|
440
|
+
if (description) {
|
|
441
|
+
return description.trim() || null;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Extract @param description from a method's JSDoc
|
|
448
|
+
* Returns the description of the first parameter (typically 'args')
|
|
449
|
+
*/
|
|
450
|
+
extractJsDocParams(method) {
|
|
451
|
+
const jsDocs = method.getJsDocs();
|
|
452
|
+
if (jsDocs.length === 0) {
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
const tags = jsDocs[0].getTags();
|
|
456
|
+
const paramTags = tags.filter(tag => tag.getTagName() === 'param');
|
|
457
|
+
if (paramTags.length === 0) {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
// Get the first @param tag (typically for the 'args' parameter)
|
|
461
|
+
const firstParamTag = paramTags[0];
|
|
462
|
+
// Extract the comment text from the param tag
|
|
463
|
+
// The tag format is: @param {type} paramName - description
|
|
464
|
+
// or: @param paramName - description
|
|
465
|
+
// or: @param paramName description
|
|
466
|
+
const tagText = firstParamTag.getText();
|
|
467
|
+
// Remove the @param prefix and extract description
|
|
468
|
+
// Pattern: @param {type}? paramName [-:]? description
|
|
469
|
+
const paramMatch = tagText.match(/@param\s+(?:\{[^}]+\}\s+)?(\w+)\s*[-:]?\s*(.*)/);
|
|
470
|
+
if (paramMatch && paramMatch[2]) {
|
|
471
|
+
const description = paramMatch[2].trim();
|
|
472
|
+
return description || null;
|
|
473
|
+
}
|
|
474
|
+
// Alternative: try to get the comment using getComment() if available
|
|
475
|
+
if ('getComment' in firstParamTag && typeof firstParamTag.getComment === 'function') {
|
|
476
|
+
const comment = firstParamTag.getComment();
|
|
477
|
+
if (typeof comment === 'string') {
|
|
478
|
+
return comment.trim() || null;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Extract service method mapping from a controller method
|
|
485
|
+
* Parses the method body to find container.get<InterfaceType>() and service.methodName() calls
|
|
486
|
+
*/
|
|
487
|
+
extractServiceMethodMapping(method, sourceFile) {
|
|
488
|
+
const imports = this.parseImports(sourceFile);
|
|
489
|
+
// Find variable declarations with container.get<InterfaceType>()
|
|
490
|
+
const variableDeclarations = method.getDescendantsOfKind(SyntaxKind.VariableDeclaration);
|
|
491
|
+
let serviceVariableName = null;
|
|
492
|
+
let interfaceType = null;
|
|
493
|
+
for (const varDecl of variableDeclarations) {
|
|
494
|
+
const initializer = varDecl.getInitializer();
|
|
495
|
+
if (!initializer || !Node.isCallExpression(initializer)) {
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
// Check if it's container.get() call
|
|
499
|
+
const callExpr = initializer;
|
|
500
|
+
const expression = callExpr.getExpression();
|
|
501
|
+
if (!Node.isPropertyAccessExpression(expression)) {
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
const objectName = expression.getExpression().getText();
|
|
505
|
+
const methodName = expression.getName();
|
|
506
|
+
if (objectName === 'container' && methodName === 'get') {
|
|
507
|
+
// Extract type argument (e.g., ICartService from container.get<ICartService>())
|
|
508
|
+
const typeArgs = callExpr.getTypeArguments();
|
|
509
|
+
if (typeArgs.length > 0) {
|
|
510
|
+
interfaceType = typeArgs[0].getText();
|
|
511
|
+
serviceVariableName = varDecl.getName();
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
if (!serviceVariableName || !interfaceType) {
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
// Find the import path for the interface type
|
|
520
|
+
const interfaceImportPath = imports.get(interfaceType);
|
|
521
|
+
if (!interfaceImportPath) {
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
// Find method call on the service variable (e.g., service.validateCart())
|
|
525
|
+
const callExpressions = method.getDescendantsOfKind(SyntaxKind.CallExpression);
|
|
526
|
+
for (const callExpr of callExpressions) {
|
|
527
|
+
const expression = callExpr.getExpression();
|
|
528
|
+
if (!Node.isPropertyAccessExpression(expression)) {
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
const objectExpr = expression.getExpression();
|
|
532
|
+
// Check if we're calling a method on our service variable
|
|
533
|
+
// Handle both `service.method()` and `await service.method()`
|
|
534
|
+
if (Node.isIdentifier(objectExpr) && objectExpr.getText() === serviceVariableName) {
|
|
535
|
+
const serviceMethodName = expression.getName();
|
|
536
|
+
return {
|
|
537
|
+
interfaceType,
|
|
538
|
+
interfaceImportPath,
|
|
539
|
+
methodName: serviceMethodName,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Extract string argument from decorator
|
|
547
|
+
*/
|
|
548
|
+
extractDecoratorStringArg(decorator) {
|
|
549
|
+
const args = decorator.getArguments();
|
|
550
|
+
if (args.length > 0) {
|
|
551
|
+
const arg = args[0];
|
|
552
|
+
if (Node.isStringLiteral(arg)) {
|
|
553
|
+
return arg.getLiteralValue();
|
|
554
|
+
}
|
|
555
|
+
// Handle template literals or other string expressions
|
|
556
|
+
const text = arg.getText();
|
|
557
|
+
if (text.startsWith('\'') || text.startsWith('"') || text.startsWith('`')) {
|
|
558
|
+
return text.slice(1, -1);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Extract object argument from decorator
|
|
565
|
+
*/
|
|
566
|
+
extractDecoratorObjectArg(decorator) {
|
|
567
|
+
const args = decorator.getArguments();
|
|
568
|
+
if (args.length > 0) {
|
|
569
|
+
const arg = args[0];
|
|
570
|
+
if (Node.isObjectLiteralExpression(arg)) {
|
|
571
|
+
const result = {};
|
|
572
|
+
for (const prop of arg.getProperties()) {
|
|
573
|
+
if (Node.isPropertyAssignment(prop)) {
|
|
574
|
+
const name = prop.getName();
|
|
575
|
+
const init = prop.getInitializer();
|
|
576
|
+
if (init) {
|
|
577
|
+
if (Node.isTrueLiteral(init)) {
|
|
578
|
+
result[name] = true;
|
|
579
|
+
}
|
|
580
|
+
else if (Node.isFalseLiteral(init)) {
|
|
581
|
+
result[name] = false;
|
|
582
|
+
}
|
|
583
|
+
else if (Node.isStringLiteral(init)) {
|
|
584
|
+
result[name] = init.getLiteralValue();
|
|
585
|
+
}
|
|
586
|
+
else if (Node.isNumericLiteral(init)) {
|
|
587
|
+
result[name] = Number(init.getText());
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return result;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Find files recursively matching a pattern
|
|
599
|
+
*/
|
|
600
|
+
findFilesRecursive(dir, pattern) {
|
|
601
|
+
const results = [];
|
|
602
|
+
if (!fs.existsSync(dir)) {
|
|
603
|
+
return results;
|
|
604
|
+
}
|
|
605
|
+
const patternRegex = this.globToRegex(pattern);
|
|
606
|
+
this.walkDirectory(dir, '', patternRegex, results);
|
|
607
|
+
return results;
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Walk directory recursively
|
|
611
|
+
*/
|
|
612
|
+
walkDirectory(baseDir, relativePath, pattern, results) {
|
|
613
|
+
const currentDir = path.join(baseDir, relativePath);
|
|
614
|
+
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
615
|
+
for (const entry of entries) {
|
|
616
|
+
const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
617
|
+
if (entry.isDirectory()) {
|
|
618
|
+
// Skip node_modules and hidden directories
|
|
619
|
+
if (!entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
|
620
|
+
this.walkDirectory(baseDir, entryRelativePath, pattern, results);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
else if (entry.isFile()) {
|
|
624
|
+
if (pattern.test(entryRelativePath)) {
|
|
625
|
+
results.push(path.join(baseDir, entryRelativePath));
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Convert glob pattern to regex
|
|
632
|
+
*/
|
|
633
|
+
globToRegex(glob) {
|
|
634
|
+
const escaped = glob
|
|
635
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
636
|
+
.replace(/\*\*/g, '{{DOUBLE_STAR}}')
|
|
637
|
+
.replace(/\*/g, '[^/]*')
|
|
638
|
+
.replace(/\{\{DOUBLE_STAR\}\}/g, '.*')
|
|
639
|
+
.replace(/\?/g, '.');
|
|
640
|
+
return new RegExp(`^${escaped}$`);
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Infer route from controller class name
|
|
644
|
+
*/
|
|
645
|
+
inferRouteFromClassName(className) {
|
|
646
|
+
// Remove 'Controller' suffix and convert to kebab-case
|
|
647
|
+
let name = className.replace(/Controller$/, '');
|
|
648
|
+
// Handle common prefixes
|
|
649
|
+
name = name.replace(/^Shop/, '');
|
|
650
|
+
// Convert PascalCase to kebab-case
|
|
651
|
+
const kebab = name
|
|
652
|
+
.replace(/([a-z])([A-Z])/g, '$1-$2')
|
|
653
|
+
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
|
|
654
|
+
.toLowerCase();
|
|
655
|
+
return `/${kebab}`;
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Extract tag from file path
|
|
659
|
+
*/
|
|
660
|
+
extractTagFromPath(relativePath) {
|
|
661
|
+
// Example: src/api/shop/cart/cartAddItemsController.ts -> cart
|
|
662
|
+
const parts = relativePath.split('/');
|
|
663
|
+
const apiIndex = parts.indexOf('api');
|
|
664
|
+
if (apiIndex >= 0 && apiIndex + 2 < parts.length) {
|
|
665
|
+
return parts[apiIndex + 2];
|
|
666
|
+
}
|
|
667
|
+
return 'general';
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Clean type name by removing import paths and generics from common wrappers
|
|
671
|
+
*/
|
|
672
|
+
cleanTypeName(typeName) {
|
|
673
|
+
// Remove import() syntax
|
|
674
|
+
let cleaned = typeName.replace(/import\([^)]+\)\./g, '');
|
|
675
|
+
// Remove namespace prefixes
|
|
676
|
+
cleaned = cleaned.replace(/\w+\./g, (match) => {
|
|
677
|
+
// Keep Temporal prefix for special types
|
|
678
|
+
if (match === 'Temporal.') {
|
|
679
|
+
return match;
|
|
680
|
+
}
|
|
681
|
+
return '';
|
|
682
|
+
});
|
|
683
|
+
return cleaned.trim();
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
//# sourceMappingURL=controller-parser.js.map
|