This commit is contained in:
2026-04-25 16:36:34 +08:00
commit db90e7579b
1876 changed files with 189777 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
{
"name": "tailchat-service-swagger-generator",
"private": true,
"version": "1.0.0",
"description": "",
"main": "index.js",
"bin": "./dist/index.js",
"scripts": {
"dev": "tsc --watch",
"prepare": "tsc",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"msgbyte",
"moonrailgun",
"tailchat"
],
"author": "moonrailgun <moonrailgun@gmail.com>",
"license": "MIT",
"dependencies": {
"globby": "11.1.0",
"ts-morph": "^16.0.0",
"ts-node": "^10.9.1"
},
"devDependencies": {
"@types/node": "^18.11.18",
"typescript": "^4.9.4"
}
}

View File

@@ -0,0 +1,31 @@
import { Project } from 'ts-morph';
import path from 'path';
import globby from 'globby';
import { processService } from './processService';
/**
* https://ts-morph.com/setup/
*/
/**
* 扫描服务
*/
async function scanServices() {
const serviceFiles = await globby('./services/**/*.service.ts');
console.time('parse project usage');
const project = new Project({
tsConfigFilePath: path.resolve(process.cwd(), './tsconfig.json'),
});
console.timeEnd('parse project usage');
console.time('parse source usage');
// 单个测试
const sourceFile = project.getSourceFileOrThrow(serviceFiles[0]);
processService(sourceFile);
console.timeEnd('parse source usage');
}
scanServices();

View File

@@ -0,0 +1,205 @@
import { SourceFile, SyntaxKind, TypeReferenceNode } from 'ts-morph';
import { getMethodParameters } from './utils';
/**
* 解析并处理 service 文件, 把所有的 Action 都自动加上jsdoc
*/
export async function processService(sourceFile: SourceFile) {
const serviceNameDeclaration = sourceFile
.getDescendantsOfKind(SyntaxKind.GetAccessor)
.find((item) => item.getSymbol().getName() === 'serviceName');
if (!serviceNameDeclaration) {
// 没有定义 serviceName, 不是一个正确的服务,跳过
return;
}
// 目前只先视为一个文件只有一个service不考虑多个
const serviceName = serviceNameDeclaration
.getFirstDescendantByKind(SyntaxKind.ReturnStatement)
.getExpression()
.asKindOrThrow(SyntaxKind.StringLiteral)
.getLiteralText();
console.log('process service:', serviceName);
const actions = findActionMethods(sourceFile);
for (const action of actions) {
const jsdocs = action.getJsDocs();
const len = jsdocs.length;
const lastJsDoc = jsdocs[len - 1]; // 最后一条记录
if (lastJsDoc && lastJsDoc.getText().includes('@swagger')) {
continue;
}
const actionName = action.getSymbol().getEscapedName();
const requestTypeReferenceNode = action
.getFirstChildByKind(SyntaxKind.Parameter)
.getFirstChildByKind(SyntaxKind.TypeReference);
const text = generateOpenapiSchemaText(
serviceName,
actionName,
lastJsDoc?.getCommentText().replaceAll('\n', ' ') ?? '',
getPropertySignatureToSwagger(requestTypeReferenceNode)
);
if (lastJsDoc) {
// 移除以前的注释
lastJsDoc.remove();
}
// 将之前的描述填写到里面
action.addJsDoc(text);
}
await sourceFile.save(); // 将改动保存到原文件中
}
/**
* 获取Action方法列表
*/
function findActionMethods(sourceFile: SourceFile) {
const actions = sourceFile
.getDescendantsOfKind(SyntaxKind.MethodDeclaration)
.filter((item) => {
const parameters = getMethodParameters(item);
if (
parameters.length === 1 &&
['TcPureContext', 'TcContext'].includes(
parameters[0].getType().getSymbol().getEscapedName()
)
) {
return true;
}
return false;
});
return actions;
}
interface SwaggerParamType {
name: string;
type: 'string' | 'integer' | 'string[]' | 'integer[]'; // https://graphql-faas.github.io/OpenAPI-Specification/versions/2.0.html#data-types
}
function generateOpenapiSchemaText(
serviceName: string,
actionName: string,
description: string,
requestParams: SwaggerParamType[],
responseParams?: 'boolean' | SwaggerParamType[]
) {
actionName = actionName.replaceAll('.', '/');
const responseData = responseParams
? responseParams === 'boolean'
? `data:
type: boolean`
: `data:
type: object
properties:
${generateProperties(responseParams, 4)}`
: '';
return `@swagger
/api/${actionName}/${serviceName}:
post:
tags:
- ${actionName}
description: ${description}
requestBody:
content:
application/json:
schema:
type: object
properties:
${generateProperties(requestParams, 14)}
responses:
200:
content:
application/json:
schema:
type: object
properties:
code:
type: integer
example: 200
message:
type: string
example: ok
${paddingWithIndent(responseData, 16)}
`;
}
function generateProperties(
params: SwaggerParamType[],
indent: number
): string {
return paddingWithIndent(
params
.map((p) => {
if (p.type.endsWith('[]')) {
const t = p.type.substring(0, p.type.length - 2);
return `${p.name}:
type: array
items:
type: ${t}`;
} else {
return `${p.name}:
type: ${p.type}`;
}
})
.join('\n'),
indent
);
}
function paddingWithIndent(text: string, indent: number) {
let indentText = '';
Array.from({ length: indent }).forEach(() => {
indentText += ' ';
});
return text.split('\n').join('\n' + indentText);
}
/**
* 将ts的类型转换为swagger可以认识的类型
*/
function getPropertySignatureToSwagger(
typeReferenceNode: TypeReferenceNode
): SwaggerParamType[] {
if (!typeReferenceNode) {
return [];
}
return typeReferenceNode
.getDescendantsOfKind(SyntaxKind.PropertySignature)
.map((item) => {
const name = item.getName();
const type = item.getType();
let typeText: SwaggerParamType['type'] = 'string';
if (type.isArray()) {
typeText = 'string[]';
if (type.isNumber()) {
typeText = 'integer[]';
}
} else {
if (type.isNumber()) {
typeText = 'integer';
}
}
return {
name,
type: typeText,
};
});
}

View File

@@ -0,0 +1,10 @@
import { MethodDeclaration, ParameterDeclaration, SyntaxKind } from 'ts-morph';
/**
* 获取函数参数列表
*/
export function getMethodParameters(
methodDeclaration: MethodDeclaration
): ParameterDeclaration[] {
return methodDeclaration.getChildrenOfKind(SyntaxKind.Parameter);
}

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"declaration": true,
"rootDir": "./src",
"outDir": "./dist",
"typeRoots": ["./node_modules/@types"],
},
"include": ["./src/**/*"],
"exclude": ["./node_modules/**/*", "./dist/**/*"]
}