@mindbase/express-common 1.0.7 → 1.0.8

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,655 @@
1
+ import {
2
+ Project,
3
+ SyntaxKind,
4
+ Node,
5
+ SourceFile,
6
+ StringLiteral,
7
+ ModuleKind,
8
+ ScriptTarget,
9
+ JSDoc,
10
+ } from "ts-morph";
11
+ import * as path from "path";
12
+ import { RouteInfo, RequestSchema, ResponseSchema, FieldValidation } from "../types/DocTypes";
13
+ import logger from "./Logger";
14
+ import { ZodSchemaParser } from "./ZodSchemaParser";
15
+ import { TSTypeParser } from "./TSTypeParser";
16
+
17
+ /**
18
+ * apidoc 标签解析结果
19
+ */
20
+ interface ApiDocTags {
21
+ params: Array<{
22
+ name: string;
23
+ type: string;
24
+ description: string;
25
+ required: boolean;
26
+ enumValues?: string[];
27
+ }>;
28
+ success: Array<{
29
+ name: string;
30
+ type: string;
31
+ description: string;
32
+ parent?: string;
33
+ }>;
34
+ }
35
+
36
+ /**
37
+ * 路由解析器
38
+ * 使用 ts-morph 解析路由文件,提取路由信息
39
+ */
40
+ export class RouteParser {
41
+ private project: Project;
42
+ private schemaParser: ZodSchemaParser;
43
+ private tsTypeParser: TSTypeParser;
44
+
45
+ constructor() {
46
+ this.project = new Project({
47
+ compilerOptions: {
48
+ module: ModuleKind.CommonJS,
49
+ target: ScriptTarget.ES2018,
50
+ allowJs: true,
51
+ },
52
+ });
53
+ this.schemaParser = new ZodSchemaParser();
54
+ this.tsTypeParser = new TSTypeParser();
55
+ }
56
+
57
+ /**
58
+ * 解析单个路由文件
59
+ * @param filePath 路由文件路径
60
+ * @returns 路由信息数组
61
+ */
62
+ public parseRouteFile(filePath: string): RouteInfo[] {
63
+ try {
64
+ const sourceFile = this.project.addSourceFileAtPath(filePath);
65
+ const routes: RouteInfo[] = [];
66
+
67
+ // 获取所有导入的 schema 文件映射
68
+ const schemaFiles = this.getImportedSchemaFiles(sourceFile, filePath);
69
+
70
+ // 获取所有类型导入映射(用于响应类型解析)
71
+ const typeImports = this.getTypeImports(sourceFile, filePath);
72
+
73
+ // 查找所有路由调用
74
+ sourceFile.forEachDescendant((node) => {
75
+ const callExpr = node as any;
76
+ if (callExpr.isKind && callExpr.isKind(SyntaxKind.CallExpression) && callExpr.getExpression && callExpr.getExpression().isKind(SyntaxKind.PropertyAccessExpression)) {
77
+ const propAccess = callExpr.getExpression();
78
+ const object = propAccess.getExpression && propAccess.getExpression().getText();
79
+ const method = propAccess.getName && propAccess.getName();
80
+
81
+ // 检查是否是 router 的 HTTP 方法调用
82
+ if (object === "router" && ["get", "post", "put", "delete", "patch"].includes(method)) {
83
+ // 提取路由路径
84
+ const args = callExpr.getArguments && callExpr.getArguments();
85
+ if (args && args.length > 0) {
86
+ const pathArg = args[0];
87
+ if (pathArg.isKind && pathArg.isKind(SyntaxKind.StringLiteral)) {
88
+ const routePath = pathArg.getText && pathArg.getText().replace(/['"]/g, "");
89
+
90
+ // 提取 JSDoc 注释
91
+ const jsDoc = callExpr.getPreviousSiblingIfKind && callExpr.getPreviousSiblingIfKind(SyntaxKind.JSDocComment);
92
+ let summary = "";
93
+ let description = "";
94
+ let requestSchema: RequestSchema | undefined;
95
+ let responseSchema: ResponseSchema | undefined;
96
+
97
+ if (jsDoc) {
98
+ const commentText = jsDoc.getCommentText && jsDoc.getCommentText();
99
+ if (commentText) {
100
+ const lines = commentText.split("\n").map((line) => line.trim());
101
+ summary = lines[0] || "";
102
+ description = lines.slice(1).join("\n").trim() || summary;
103
+ }
104
+
105
+ // 解析 apidoc 格式的注释标签
106
+ try {
107
+ const fullCommentText = jsDoc.getFullText?.() || commentText || "";
108
+ const apiDocTags = this.parseApiDocTags(fullCommentText);
109
+
110
+ // 从 @apiParam 构建请求 Schema(优先级高于 validate 中间件)
111
+ if (apiDocTags.params.length > 0) {
112
+ requestSchema = this.buildRequestSchemaFromTags(apiDocTags.params);
113
+ }
114
+
115
+ // 从 @apiSuccess 构建响应 Schema
116
+ if (apiDocTags.success.length > 0) {
117
+ responseSchema = this.buildResponseSchemaFromTags(apiDocTags.success);
118
+ }
119
+ } catch (error) {
120
+ logger.warn(`解析 apidoc 注释失败: ${filePath}:${routePath}`, error);
121
+ }
122
+
123
+ // 如果没有 @apiSuccess,尝试提取 @returns 标签(降级方案)
124
+ if (!responseSchema) {
125
+ responseSchema = this.extractResponseSchema(jsDoc, sourceFile, typeImports);
126
+ }
127
+ }
128
+
129
+ // 提取中间件信息
130
+ const middlewares: string[] = [];
131
+ for (let i = 1; i < args.length - 1; i++) {
132
+ const arg = args[i];
133
+ if (arg.isKind(SyntaxKind.CallExpression)) {
134
+ const mwCallExpr = arg as any;
135
+ const expr = mwCallExpr.getExpression();
136
+ if (expr.isKind(SyntaxKind.Identifier)) {
137
+ middlewares.push(expr.getText());
138
+ } else if (expr.isKind(SyntaxKind.PropertyAccessExpression)) {
139
+ middlewares.push(expr.getText());
140
+ }
141
+ }
142
+ }
143
+
144
+ // 如果没有从注释获取到请求 Schema,尝试从 validate 中间件获取(降级方案)
145
+ if (!requestSchema) {
146
+ requestSchema = this.extractRequestSchema(args, schemaFiles);
147
+ }
148
+
149
+ const routeInfo: RouteInfo = {
150
+ module: path.basename(path.dirname(filePath)).toLowerCase(),
151
+ method: method.toUpperCase(),
152
+ path: routePath,
153
+ fullPath: "", // 完整路径会在注册时填充
154
+ summary,
155
+ description,
156
+ requestSchema,
157
+ responseSchema,
158
+ middlewares,
159
+ filePath,
160
+ };
161
+
162
+ routes.push(routeInfo);
163
+ }
164
+ }
165
+ }
166
+ }
167
+ });
168
+
169
+ return routes;
170
+ } catch (error) {
171
+ logger.error(`解析路由文件失败: ${filePath}`, error);
172
+ return [];
173
+ }
174
+ }
175
+
176
+ /**
177
+ * 获取导入的 schema 文件映射
178
+ * @returns Map<变量名, 文件绝对路径>
179
+ */
180
+ private getImportedSchemaFiles(
181
+ sourceFile: SourceFile,
182
+ currentFilePath: string
183
+ ): Map<string, string> {
184
+ const schemaFiles = new Map<string, string>();
185
+
186
+ for (const importDecl of sourceFile.getImportDeclarations()) {
187
+ const source = importDecl.getModuleSpecifierValue();
188
+
189
+ // 检查是否是 schema 相关的导入
190
+ if (source.includes(".schema") || source.includes("/zod/") || source.includes("\\zod\\")) {
191
+ // 解析相对路径为绝对路径
192
+ const absolutePath = this.resolveImportPath(
193
+ currentFilePath,
194
+ source
195
+ );
196
+ if (absolutePath) {
197
+ // 记录导入的变量名 -> 文件路径映射
198
+ for (const specifier of importDecl.getNamedImports()) {
199
+ logger.debug(`发现 schema 导入: ${specifier.getName()} -> ${absolutePath}`);
200
+ schemaFiles.set(specifier.getName(), absolutePath);
201
+ }
202
+ } else {
203
+ logger.debug(`无法解析 schema 导入路径: ${source}`);
204
+ }
205
+ }
206
+ }
207
+
208
+ return schemaFiles;
209
+ }
210
+
211
+ /**
212
+ * 获取所有类型导入映射(用于响应类型解析)
213
+ * @returns Map<类型名, 文件绝对路径>
214
+ */
215
+ private getTypeImports(
216
+ sourceFile: SourceFile,
217
+ currentFilePath: string
218
+ ): Map<string, string> {
219
+ const typeImports = new Map<string, string>();
220
+
221
+ for (const importDecl of sourceFile.getImportDeclarations()) {
222
+ const source = importDecl.getModuleSpecifierValue();
223
+
224
+ // 解析相对路径为绝对路径
225
+ const absolutePath = this.resolveImportPath(currentFilePath, source);
226
+ if (absolutePath) {
227
+ // 记录所有命名导入
228
+ for (const specifier of importDecl.getNamedImports()) {
229
+ typeImports.set(specifier.getName(), absolutePath);
230
+ }
231
+ }
232
+ }
233
+
234
+ return typeImports;
235
+ }
236
+
237
+ /**
238
+ * 从 JSDoc 中提取响应 Schema
239
+ */
240
+ private extractResponseSchema(
241
+ jsDoc: JSDoc,
242
+ sourceFile: SourceFile,
243
+ typeImports: Map<string, string>
244
+ ): ResponseSchema | undefined {
245
+ // 获取所有 JSDoc 标签
246
+ const tags = jsDoc.getTags();
247
+
248
+ for (const tag of tags) {
249
+ const tagName = tag.getTagName();
250
+
251
+ // 检查 @returns 或 @return 标签
252
+ if (tagName === "returns" || tagName === "return") {
253
+ // 获取标签的完整文本
254
+ const tagText = tag.getText?.() || "";
255
+ logger.debug(`@returns 标签文本: ${tagText}`);
256
+
257
+ // 尝试从标签文本中提取类型 {TypeName}
258
+ const typeMatch = tagText.match(/@\s*returns?\s*\{([^}]+)\}/i);
259
+ if (typeMatch) {
260
+ const typeString = typeMatch[1].trim();
261
+ logger.debug(`从标签文本提取类型: ${typeString}`);
262
+
263
+ const responseSchema = this.tsTypeParser.parseResponseType(
264
+ sourceFile,
265
+ typeString,
266
+ typeImports
267
+ );
268
+
269
+ if (responseSchema) {
270
+ return responseSchema;
271
+ }
272
+ }
273
+
274
+ // 尝试从注释中提取类型
275
+ const comment = tag.getCommentText?.();
276
+ if (comment) {
277
+ const commentMatch = comment.match(/^\{([^}]+)\}/);
278
+ if (commentMatch) {
279
+ const typeString = commentMatch[1].trim();
280
+ logger.debug(`从注释提取类型: ${typeString}`);
281
+
282
+ const responseSchema = this.tsTypeParser.parseResponseType(
283
+ sourceFile,
284
+ typeString,
285
+ typeImports
286
+ );
287
+
288
+ if (responseSchema) {
289
+ return responseSchema;
290
+ }
291
+ }
292
+ }
293
+
294
+ // 尝试使用 ts-morph 的类型表达式
295
+ const typeExpression = (tag as any).getTypeExpression?.();
296
+ if (typeExpression) {
297
+ const typeNode = typeExpression.getType?.();
298
+ if (typeNode) {
299
+ const typeString = typeNode.getText();
300
+ logger.debug(`从类型表达式提取类型: ${typeString}`);
301
+
302
+ if (typeString && typeString !== "any") {
303
+ const responseSchema = this.tsTypeParser.parseResponseType(
304
+ sourceFile,
305
+ typeString,
306
+ typeImports
307
+ );
308
+
309
+ if (responseSchema) {
310
+ return responseSchema;
311
+ }
312
+ }
313
+ }
314
+ }
315
+ }
316
+ }
317
+
318
+ return undefined;
319
+ }
320
+
321
+ /**
322
+ * 解析导入路径为绝对路径
323
+ */
324
+ private resolveImportPath(
325
+ currentFilePath: string,
326
+ importPath: string
327
+ ): string | null {
328
+ try {
329
+ // 处理相对路径
330
+ if (importPath.startsWith(".")) {
331
+ const currentDir = path.dirname(currentFilePath);
332
+ const absolutePath = path.resolve(currentDir, importPath);
333
+
334
+ // 尝试添加 .ts 扩展名
335
+ const tsPath = absolutePath.endsWith(".ts")
336
+ ? absolutePath
337
+ : `${absolutePath}.ts`;
338
+ return tsPath;
339
+ }
340
+
341
+ // 非相对路径暂不处理
342
+ return null;
343
+ } catch (error) {
344
+ logger.warn(`解析导入路径失败: ${importPath}`);
345
+ return null;
346
+ }
347
+ }
348
+
349
+ /**
350
+ * 提取请求 Schema
351
+ */
352
+ private extractRequestSchema(
353
+ args: any[],
354
+ schemaFiles: Map<string, string>
355
+ ): RequestSchema | undefined {
356
+ for (const arg of args) {
357
+ // 路由参数可能是 validate(...) 或 validate(...) as any
358
+ // 需要先处理 as any 类型断言
359
+ let targetArg = arg;
360
+ if (
361
+ arg.isKind &&
362
+ arg.isKind(SyntaxKind.AsExpression)
363
+ ) {
364
+ targetArg = arg.getExpression();
365
+ }
366
+
367
+ // 检查是否是 validate 调用
368
+ if (
369
+ targetArg.isKind &&
370
+ targetArg.isKind(SyntaxKind.CallExpression) &&
371
+ targetArg.getExpression &&
372
+ targetArg.getExpression().isKind &&
373
+ targetArg.getExpression().isKind(SyntaxKind.Identifier) &&
374
+ targetArg.getExpression().getText() === "validate"
375
+ ) {
376
+ const validateArgs = targetArg.getArguments();
377
+ if (validateArgs.length === 0) continue;
378
+
379
+ // 获取 schema 变量名
380
+ const schemaArg = validateArgs[0];
381
+ let schemaName: string;
382
+
383
+ // 处理 schema 参数中的 as any 类型断言
384
+ if (
385
+ schemaArg.isKind &&
386
+ schemaArg.isKind(SyntaxKind.AsExpression)
387
+ ) {
388
+ schemaName = schemaArg.getExpression().getText();
389
+ } else {
390
+ schemaName = schemaArg.getText();
391
+ }
392
+
393
+ // 获取 target 参数
394
+ let target: "body" | "query" | "params" = "body";
395
+ if (validateArgs.length > 1) {
396
+ const targetArgParam = validateArgs[1];
397
+ if (
398
+ targetArgParam.isKind &&
399
+ targetArgParam.isKind(SyntaxKind.StringLiteral)
400
+ ) {
401
+ target = (targetArgParam as StringLiteral).getLiteralValue() as
402
+ | "body"
403
+ | "query"
404
+ | "params";
405
+ }
406
+ }
407
+
408
+ // 解析 schema 文件
409
+ const schemaFilePath = schemaFiles.get(schemaName);
410
+ if (schemaFilePath) {
411
+ try {
412
+ const schemaSourceFile =
413
+ this.project.addSourceFileAtPath(schemaFilePath);
414
+ const fields = this.schemaParser.parseSchemaVariable(
415
+ schemaSourceFile,
416
+ schemaName
417
+ );
418
+
419
+ if (fields) {
420
+ return { target, fields };
421
+ }
422
+ } catch (error) {
423
+ logger.warn(`解析 schema 文件失败: ${schemaFilePath}`, error);
424
+ }
425
+ } else {
426
+ logger.debug(`未找到 schema 文件映射: ${schemaName}`);
427
+ }
428
+ }
429
+ }
430
+
431
+ return undefined;
432
+ }
433
+
434
+ /**
435
+ * 解析多个路由文件
436
+ * @param filePaths 路由文件路径数组
437
+ * @returns 路由信息数组
438
+ */
439
+ public parseRouteFiles(filePaths: string[]): RouteInfo[] {
440
+ const allRoutes: RouteInfo[] = [];
441
+ for (const filePath of filePaths) {
442
+ const routes = this.parseRouteFile(filePath);
443
+ allRoutes.push(...routes);
444
+ }
445
+ return allRoutes;
446
+ }
447
+
448
+ /**
449
+ * 解析 apidoc 格式的注释标签
450
+ * @param commentText JSDoc 注释文本
451
+ * @returns 解析出的标签信息
452
+ */
453
+ private parseApiDocTags(commentText: string): ApiDocTags {
454
+ const tags: ApiDocTags = { params: [], success: [] };
455
+
456
+ // 解析 @apiParam 标签
457
+ // 格式: @apiParam {type} [name] description
458
+ const paramRegex = /@apiParam\s+\{([^}]+)\}\s*(?:\[([^\]]+)\]|(\S+))\s*(.*)/g;
459
+ let match;
460
+ while ((match = paramRegex.exec(commentText)) !== null) {
461
+ const [, typeStr, optionalName, requiredName, description] = match;
462
+ const name = optionalName || requiredName;
463
+ const required = !optionalName;
464
+
465
+ // 解析类型字符串
466
+ const typeInfo = this.parseParamType(typeStr);
467
+
468
+ tags.params.push({
469
+ name,
470
+ type: typeInfo.type,
471
+ description: description.trim(),
472
+ required,
473
+ enumValues: typeInfo.enumValues,
474
+ });
475
+ }
476
+
477
+ // 解析 @apiSuccess 标签
478
+ // 格式: @apiSuccess {type} field 或 @apiSuccess {type} parent.field
479
+ const successRegex = /@apiSuccess\s+\{([^}]+)\}\s+([^\s]+)\s*(.*)/g;
480
+ while ((match = successRegex.exec(commentText)) !== null) {
481
+ const [, typeStr, fieldPath, description] = match;
482
+
483
+ // 解析字段路径 (如 "data.user.id")
484
+ const parts = fieldPath.split(".");
485
+ const name = parts[parts.length - 1];
486
+ const parent = parts.length > 1 ? parts.slice(0, -1).join(".") : undefined;
487
+
488
+ // 解析类型字符串
489
+ const typeInfo = this.parseParamType(typeStr);
490
+
491
+ tags.success.push({
492
+ name,
493
+ type: typeInfo.type,
494
+ description: description.trim(),
495
+ parent,
496
+ });
497
+ }
498
+
499
+ return tags;
500
+ }
501
+
502
+ /**
503
+ * 解析 apidoc 类型字符串
504
+ * @param typeStr 类型字符串,如 "string", "string=", "number[]", {string="a","b"}
505
+ * @returns 解析后的类型信息
506
+ */
507
+ private parseParamType(typeStr: string): {
508
+ type: FieldValidation["type"];
509
+ array?: boolean;
510
+ enumValues?: string[];
511
+ } {
512
+ typeStr = typeStr.trim();
513
+
514
+ // 处理枚举类型: {string="a","b"}
515
+ const enumMatch = typeStr.match(/^(\w+)=(.+)$/);
516
+ if (enumMatch) {
517
+ const baseType = enumMatch[1];
518
+ const enumValues = enumMatch[2]
519
+ .split(",")
520
+ .map((v) => v.trim().replace(/^["']|["']$/g, ""));
521
+
522
+ return {
523
+ type: this.mapTypeName(baseType),
524
+ enumValues,
525
+ };
526
+ }
527
+
528
+ // 处理数组类型: number[]
529
+ if (typeStr.endsWith("[]")) {
530
+ const baseType = typeStr.slice(0, -2);
531
+ return {
532
+ type: this.mapTypeName(baseType),
533
+ array: true,
534
+ };
535
+ }
536
+
537
+ // 处理可空类型: string?
538
+ if (typeStr.endsWith("?")) {
539
+ const baseType = typeStr.slice(0, -1);
540
+ return {
541
+ type: this.mapTypeName(baseType),
542
+ };
543
+ }
544
+
545
+ // 基础类型
546
+ return {
547
+ type: this.mapTypeName(typeStr),
548
+ };
549
+ }
550
+
551
+ /**
552
+ * 映射类型名称到 FieldValidation 类型
553
+ */
554
+ private mapTypeName(typeName: string): FieldValidation["type"] {
555
+ const typeMap: Record<string, FieldValidation["type"]> = {
556
+ string: "string",
557
+ number: "number",
558
+ integer: "number",
559
+ boolean: "boolean",
560
+ array: "array",
561
+ object: "object",
562
+ file: "file",
563
+ date: "date",
564
+ };
565
+
566
+ return typeMap[typeName.toLowerCase()] || "any";
567
+ }
568
+
569
+ /**
570
+ * 从 @apiParam 标签构建请求 Schema
571
+ */
572
+ private buildRequestSchemaFromTags(
573
+ params: ApiDocTags["params"]
574
+ ): RequestSchema {
575
+ const fields: Record<string, FieldValidation> = {};
576
+
577
+ for (const param of params) {
578
+ fields[param.name] = {
579
+ type: param.type as FieldValidation["type"],
580
+ required: param.required,
581
+ constraints: param.enumValues
582
+ ? { custom: [`可选值: ${param.enumValues.join(", ")}`] }
583
+ : undefined,
584
+ };
585
+ }
586
+
587
+ return { target: "body", fields };
588
+ }
589
+
590
+ /**
591
+ * 从 @apiSuccess 标签构建响应 Schema
592
+ */
593
+ private buildResponseSchemaFromTags(
594
+ success: ApiDocTags["success"]
595
+ ): ResponseSchema {
596
+ const fields: Record<string, FieldValidation> = {};
597
+
598
+ // 先处理所有顶级字段,为嵌套字段做准备
599
+ for (const field of success) {
600
+ if (!field.parent && !fields[field.name]) {
601
+ // 顶级字段,如果是 object 类型,添加 properties 属性
602
+ if (field.type === "object") {
603
+ fields[field.name] = {
604
+ type: "object",
605
+ required: true,
606
+ properties: {},
607
+ };
608
+ } else {
609
+ fields[field.name] = {
610
+ type: field.type as FieldValidation["type"],
611
+ required: true,
612
+ };
613
+ }
614
+ }
615
+ }
616
+
617
+ // 处理嵌套字段
618
+ for (const field of success) {
619
+ if (field.parent) {
620
+ // 处理嵌套字段 (如 data.user.id)
621
+ const parts = field.parent.split(".");
622
+ let current = fields;
623
+
624
+ // 创建/获取父级对象链
625
+ for (let i = 0; i < parts.length; i++) {
626
+ const part = parts[i];
627
+ if (!current[part]) {
628
+ // 如果父级字段不存在,创建它
629
+ current[part] = {
630
+ type: "object",
631
+ required: true,
632
+ properties: {},
633
+ };
634
+ } else if (!current[part].properties) {
635
+ // 如果父级字段存在但没有 properties(作为顶级字段创建的),添加 properties
636
+ current[part].properties = {};
637
+ }
638
+ // 进入到下一级的 properties
639
+ current = current[part].properties!;
640
+ }
641
+
642
+ // 添加叶子节点到最内层对象的 properties
643
+ current[field.name] = {
644
+ type: field.type as FieldValidation["type"],
645
+ required: true,
646
+ };
647
+ }
648
+ }
649
+
650
+ return { fields };
651
+ }
652
+ }
653
+
654
+ // 导出单例实例
655
+ export const routeParser = new RouteParser();