chore: 并入 iotlcm-admin-ui 历史到 admin-ui/(原提交 f305eab)
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { PluginOption } from 'vite';
|
||||
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { constants as fsConstants } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
interface ApiTypeGeneratorOptions {
|
||||
apiRoot: string;
|
||||
}
|
||||
|
||||
interface DirNode {
|
||||
children?: DirNode[];
|
||||
key: string;
|
||||
title: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface WriteTypePayload {
|
||||
autoImport?: boolean;
|
||||
content?: string;
|
||||
dir?: string;
|
||||
fileName?: string;
|
||||
overwrite?: boolean;
|
||||
}
|
||||
|
||||
interface CheckTypeFilePayload {
|
||||
items?: CheckTypeFileItem[];
|
||||
}
|
||||
|
||||
interface CheckTypeFileItem {
|
||||
dir?: string;
|
||||
fileName?: string;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
const API_TYPE_GENERATOR_PREFIX = '/__api-type-generator__';
|
||||
|
||||
export function apiTypeGeneratorPlugin(
|
||||
options: ApiTypeGeneratorOptions,
|
||||
): PluginOption {
|
||||
const apiRoot = path.resolve(options.apiRoot);
|
||||
|
||||
return {
|
||||
apply: 'serve',
|
||||
configureServer(server) {
|
||||
server.middlewares.use(async (request, response, next) => {
|
||||
const url = request.url?.split('?')[0] ?? '';
|
||||
|
||||
if (!url.startsWith(API_TYPE_GENERATOR_PREFIX)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
request.method === 'GET' &&
|
||||
url === `${API_TYPE_GENERATOR_PREFIX}/dirs`
|
||||
) {
|
||||
const data = await scanApiDirs(apiRoot);
|
||||
|
||||
sendJson(response, 200, { data, success: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
request.method === 'POST' &&
|
||||
url === `${API_TYPE_GENERATOR_PREFIX}/exists`
|
||||
) {
|
||||
const payload = await readJsonBody<CheckTypeFilePayload>(request);
|
||||
const data = await checkTypeFiles(apiRoot, payload);
|
||||
|
||||
sendJson(response, 200, { data, success: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
request.method === 'POST' &&
|
||||
url === `${API_TYPE_GENERATOR_PREFIX}/write`
|
||||
) {
|
||||
const payload = await readJsonBody<WriteTypePayload>(request);
|
||||
const data = await writeTypeFile(apiRoot, payload);
|
||||
|
||||
sendJson(response, 200, { data, success: true });
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 404, {
|
||||
message: '接口不存在',
|
||||
success: false,
|
||||
});
|
||||
} catch (error) {
|
||||
const statusCode =
|
||||
error instanceof ApiTypeGeneratorError ? error.status : 500;
|
||||
|
||||
sendJson(response, statusCode, {
|
||||
code:
|
||||
error instanceof ApiTypeGeneratorError ? error.code : 'UNKNOWN',
|
||||
message: error instanceof Error ? error.message : '生成失败',
|
||||
success: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
name: 'web-antd:api-type-generator',
|
||||
};
|
||||
}
|
||||
|
||||
class ApiTypeGeneratorError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status = 400,
|
||||
public code = 'BAD_REQUEST',
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function accessFile(filePath: string) {
|
||||
try {
|
||||
await fs.access(filePath, fsConstants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureInApiRoot(apiRoot: string, targetPath: string) {
|
||||
const relativePath = path.relative(apiRoot, targetPath);
|
||||
|
||||
if (
|
||||
relativePath.startsWith('..') ||
|
||||
path.isAbsolute(relativePath) ||
|
||||
relativePath === ''
|
||||
) {
|
||||
if (relativePath === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new ApiTypeGeneratorError('目标路径不在 src/api 目录下');
|
||||
}
|
||||
}
|
||||
|
||||
function extractTypeNames(content: string) {
|
||||
const names = new Set<string>();
|
||||
const regex = /export\s+(?:interface|type)\s+([A-Za-z_$][\w$]*)/g;
|
||||
const results = content.matchAll(regex);
|
||||
|
||||
for (const result of results) {
|
||||
const typeName = result[1];
|
||||
|
||||
if (!/(?:Param|Res)$/u.test(typeName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
names.add(typeName);
|
||||
}
|
||||
|
||||
return [...names];
|
||||
}
|
||||
|
||||
function formatTypeImport(typeNames: string[], importPath: string) {
|
||||
const sortedNames = [...new Set(typeNames)].toSorted();
|
||||
|
||||
if (sortedNames.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (sortedNames.length === 1) {
|
||||
return `import type { ${sortedNames[0]} } from '${importPath}';`;
|
||||
}
|
||||
|
||||
return `import type {\n ${sortedNames.join(',\n ')},\n} from '${importPath}';`;
|
||||
}
|
||||
|
||||
function getImportPath(fileName: string) {
|
||||
return `./${fileName.replace(/\.ts$/u, '')}`;
|
||||
}
|
||||
|
||||
function getSafeFileName(fileName?: string) {
|
||||
const safeFileName = fileName?.trim() || 'type.ts';
|
||||
|
||||
if (
|
||||
!/^[\w.-]+\.ts$/u.test(safeFileName) ||
|
||||
safeFileName.includes('..') ||
|
||||
safeFileName.includes('/') ||
|
||||
safeFileName.includes('\\')
|
||||
) {
|
||||
throw new ApiTypeGeneratorError('文件名必须是单个 .ts 文件名');
|
||||
}
|
||||
|
||||
return safeFileName;
|
||||
}
|
||||
|
||||
function getSafeRelativeDir(dir?: string) {
|
||||
const safeDir = (dir ?? '').trim().replaceAll('\\', '/');
|
||||
|
||||
if (!safeDir || safeDir === '.') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (path.isAbsolute(safeDir) || safeDir.split('/').includes('..')) {
|
||||
throw new ApiTypeGeneratorError('目录路径不合法');
|
||||
}
|
||||
|
||||
return safeDir.split('/').filter(Boolean).join('/');
|
||||
}
|
||||
|
||||
function mergeTypeImport(
|
||||
source: string,
|
||||
importPath: string,
|
||||
typeNames: string[],
|
||||
) {
|
||||
const importRegex = new RegExp(
|
||||
String.raw`import\s+type\s+\{([\s\S]*?)\}\s+from\s+['"]${escapeRegExp(
|
||||
importPath,
|
||||
)}['"];?`,
|
||||
'u',
|
||||
);
|
||||
const matchedImport = source.match(importRegex);
|
||||
|
||||
if (matchedImport) {
|
||||
const existedNames = matchedImport[1]
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
const nextImport = formatTypeImport(
|
||||
[...existedNames, ...typeNames],
|
||||
importPath,
|
||||
);
|
||||
|
||||
return source.replace(importRegex, nextImport);
|
||||
}
|
||||
|
||||
const importStatement = formatTypeImport(typeNames, importPath);
|
||||
|
||||
if (!importStatement) {
|
||||
return source;
|
||||
}
|
||||
|
||||
return `${importStatement}\n\n${source}`;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
|
||||
}
|
||||
|
||||
async function readJsonBody<T>(request: IncomingMessage) {
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
for await (const chunk of request) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
|
||||
const content = Buffer.concat(chunks).toString('utf8');
|
||||
|
||||
if (!content) {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(content) as T;
|
||||
} catch {
|
||||
throw new ApiTypeGeneratorError('请求体必须是合法 JSON');
|
||||
}
|
||||
}
|
||||
|
||||
async function scanApiDirs(apiRoot: string) {
|
||||
async function scan(currentDir: string): Promise<DirNode[]> {
|
||||
const dirents = await fs.readdir(currentDir, { withFileTypes: true });
|
||||
const nodes = await Promise.all(
|
||||
dirents
|
||||
.filter((dirent) => dirent.isDirectory())
|
||||
.toSorted((prev, next) => prev.name.localeCompare(next.name))
|
||||
.map(async (dirent) => {
|
||||
const fullPath = path.join(currentDir, dirent.name);
|
||||
const relativePath = path
|
||||
.relative(apiRoot, fullPath)
|
||||
.replaceAll(path.sep, '/');
|
||||
const children = await scan(fullPath);
|
||||
|
||||
return {
|
||||
children: children.length > 0 ? children : undefined,
|
||||
key: relativePath,
|
||||
title: dirent.name,
|
||||
value: relativePath,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
children: await scan(apiRoot),
|
||||
key: '.',
|
||||
title: 'api',
|
||||
value: '.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function checkTypeFiles(
|
||||
apiRoot: string,
|
||||
payload: CheckTypeFilePayload,
|
||||
) {
|
||||
const items = payload.items ?? [];
|
||||
|
||||
if (!Array.isArray(items)) {
|
||||
throw new ApiTypeGeneratorError('检查参数必须是数组');
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
items.map(async (item, index) => {
|
||||
const relativeDir = getSafeRelativeDir(item.dir);
|
||||
const fileName = getSafeFileName(item.fileName);
|
||||
const targetDir = path.resolve(apiRoot, relativeDir);
|
||||
|
||||
ensureInApiRoot(apiRoot, targetDir);
|
||||
|
||||
const targetPath = path.resolve(targetDir, fileName);
|
||||
|
||||
ensureInApiRoot(apiRoot, targetPath);
|
||||
|
||||
return {
|
||||
dir: relativeDir || '.',
|
||||
exists: await accessFile(targetPath),
|
||||
fileName,
|
||||
filePath: path.relative(apiRoot, targetPath).replaceAll(path.sep, '/'),
|
||||
key: item.key ?? String(index),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function sendJson(response: ServerResponse, statusCode: number, data: unknown) {
|
||||
response.statusCode = statusCode;
|
||||
response.setHeader('Content-Type', 'application/json;charset=utf-8');
|
||||
response.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
async function updateIndexTypeImport(
|
||||
targetDir: string,
|
||||
fileName: string,
|
||||
content: string,
|
||||
) {
|
||||
const indexPath = path.join(targetDir, 'index.ts');
|
||||
const hasIndexFile = await accessFile(indexPath);
|
||||
|
||||
if (!hasIndexFile) {
|
||||
return {
|
||||
importSkipped: true,
|
||||
importUpdated: false,
|
||||
};
|
||||
}
|
||||
|
||||
const typeNames = extractTypeNames(content);
|
||||
|
||||
if (typeNames.length === 0) {
|
||||
return {
|
||||
importSkipped: true,
|
||||
importUpdated: false,
|
||||
};
|
||||
}
|
||||
|
||||
const source = await fs.readFile(indexPath, 'utf8');
|
||||
const nextSource = mergeTypeImport(
|
||||
source,
|
||||
getImportPath(fileName),
|
||||
typeNames,
|
||||
);
|
||||
|
||||
if (nextSource !== source) {
|
||||
await fs.writeFile(indexPath, nextSource, 'utf8');
|
||||
}
|
||||
|
||||
return {
|
||||
importSkipped: false,
|
||||
importUpdated: nextSource !== source,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeTypeFile(apiRoot: string, payload: WriteTypePayload) {
|
||||
const relativeDir = getSafeRelativeDir(payload.dir);
|
||||
const fileName = getSafeFileName(payload.fileName);
|
||||
const content = payload.content ?? '';
|
||||
|
||||
if (!content.trim()) {
|
||||
throw new ApiTypeGeneratorError('类型定义内容不能为空');
|
||||
}
|
||||
|
||||
const targetDir = path.resolve(apiRoot, relativeDir);
|
||||
|
||||
ensureInApiRoot(apiRoot, targetDir);
|
||||
|
||||
const targetPath = path.resolve(targetDir, fileName);
|
||||
|
||||
ensureInApiRoot(apiRoot, targetPath);
|
||||
|
||||
const exists = await accessFile(targetPath);
|
||||
|
||||
if (exists && !payload.overwrite) {
|
||||
throw new ApiTypeGeneratorError('目标文件已存在', 409, 'FILE_EXISTS');
|
||||
}
|
||||
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
targetPath,
|
||||
content.endsWith('\n') ? content : `${content}\n`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const importResult = payload.autoImport
|
||||
? await updateIndexTypeImport(targetDir, fileName, content)
|
||||
: {
|
||||
importSkipped: false,
|
||||
importUpdated: false,
|
||||
};
|
||||
|
||||
return {
|
||||
exists,
|
||||
filePath: path.relative(apiRoot, targetPath).replaceAll(path.sep, '/'),
|
||||
...importResult,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user