apifox-client.ts•15.8 kB
import axios, { AxiosInstance } from 'axios';
/**
* Apifox API 客户端配置
*/
export interface ApifoxConfig {
/** API 访问令牌 */
token: string;
/** 项目 ID */
projectId: string;
/** API 基础 URL,默认为 https://api.apifox.com */
baseUrl?: string;
}
/**
* API 接口定义
*/
export interface ApiDefinition {
/** 接口 ID(更新和删除时需要) */
id?: string;
/** 接口名称 */
name: string;
/** 接口路径 */
path: string;
/** HTTP 方法 */
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
/** 接口描述 */
description?: string;
/** 目录 ID(可选) */
folderId?: string;
/** 请求参数 */
parameters?: any[];
/** 请求体 */
requestBody?: any;
/** 响应定义 */
responses?: any;
/** 标签 */
tags?: string[];
}
/**
* API 列表查询参数
*/
export interface ApiListQuery {
/** 页码 */
page?: number;
/** 每页数量 */
pageSize?: number;
/** 搜索关键词 */
keyword?: string;
/** 目录 ID */
folderId?: string;
}
/**
* Apifox API 客户端
* 用于与 Apifox 开放 API 进行交互
*
* 注意:Apifox Open API 功能有限,经过测试只有导入功能可用
*/
export class ApifoxClient {
private client: AxiosInstance;
private projectId: string;
constructor(config: ApifoxConfig) {
this.projectId = config.projectId;
const baseURL = config.baseUrl || 'https://api.apifox.com';
this.client = axios.create({
baseURL,
headers: {
'Authorization': `Bearer ${config.token}`,
'Content-Type': 'application/json',
'X-Apifox-Api-Version': '2024-03-28'
},
timeout: 30000
});
// 添加响应拦截器处理错误
this.client.interceptors.response.use(
response => response,
error => {
const status = error.response?.status;
const message = error.response?.data?.errorMessage || error.response?.data?.message || error.message;
const errorCode = error.response?.data?.errorCode;
let errorMsg = `Apifox API 错误`;
if (errorCode) {
errorMsg += ` (${errorCode})`;
}
if (status) {
errorMsg += ` [${status}]`;
}
errorMsg += `: ${message}`;
throw new Error(errorMsg);
}
);
}
/**
* 检测路径前缀,用于智能范围限定
* 自动识别导入规范涉及的路径范围
*
* @param paths 路径列表
* @returns 路径前缀列表
*/
private detectPathPrefixes(paths: string[]): string[] {
if (paths.length === 0) {
return [];
}
// 将所有路径按层级分组
const pathsBySegments: string[][] = paths.map(p => p.split('/').filter(s => s));
// 找出最长公共前缀深度
let commonDepth = 0;
if (pathsBySegments.length > 0) {
const minLength = Math.min(...pathsBySegments.map(p => p.length));
for (let depth = 0; depth < minLength; depth++) {
const firstSegment = pathsBySegments[0][depth];
const allSame = pathsBySegments.every(p => p[depth] === firstSegment);
if (allSame) {
commonDepth = depth + 1;
} else {
break;
}
}
}
// 尝试找出合适的分组深度(比公共前缀深1-2层)
const groupingDepth = Math.min(commonDepth + 2, Math.max(...pathsBySegments.map(p => p.length)) - 1);
// 按分组深度提取前缀
const prefixSet = new Set<string>();
for (const segments of pathsBySegments) {
const depth = Math.min(groupingDepth, segments.length);
if (depth > 0) {
const prefix = '/' + segments.slice(0, depth).join('/');
prefixSet.add(prefix);
}
}
// 去重并过滤子前缀
const prefixes = Array.from(prefixSet).sort((a, b) => a.length - b.length);
const finalPrefixes: string[] = [];
for (const prefix of prefixes) {
// 检查是否已经有更短的前缀包含了这个前缀
const isSubPrefix = finalPrefixes.some(existing => prefix.startsWith(existing + '/'));
if (!isSubPrefix) {
finalPrefixes.push(prefix);
}
}
return finalPrefixes;
}
/**
* 批量导入 OpenAPI/Swagger 规范到 Apifox 项目
*
* @param spec OpenAPI 3.0/3.1 或 Swagger 2.0 规范(JSON 对象)
* @param options 导入选项
* @returns 导入结果,包含各类资源的创建/更新/错误计数
*/
async importOpenApi(spec: any, options?: {
endpointOverwriteBehavior?: 'OVERWRITE_EXISTING' | 'AUTO_MERGE' | 'KEEP_EXISTING' | 'CREATE_NEW';
schemaOverwriteBehavior?: 'OVERWRITE_EXISTING' | 'AUTO_MERGE' | 'KEEP_EXISTING' | 'CREATE_NEW';
updateFolderOfChangedEndpoint?: boolean;
prependBasePath?: boolean;
targetBranchId?: number;
markDeprecatedEndpoints?: boolean;
confirmHighDeprecation?: boolean;
}): Promise<any> {
let finalSpec = spec;
const warnings: string[] = []; // 收集警告信息
let deprecatedInfo: { count: number; ratio: number; scope: string[] } | null = null;
// 如果启用了标记废弃接口功能
if (options?.markDeprecatedEndpoints) {
try {
// 1. 先导出现有的 OpenAPI 规范
const existingSpec = await this.exportOpenApi({
oasVersion: spec.openapi?.startsWith('3.1') ? '3.1' : '3.0',
exportFormat: 'JSON'
});
// 2. 智能检测路径前缀范围
const newPaths = spec.paths || {};
const newPathKeys = Object.keys(newPaths);
// 安全检查:防止空 spec 导致全部标记为废弃
if (newPathKeys.length === 0) {
const warning = '⚠️ 检测到空规范:新规范没有任何接口(paths 为空),已自动跳过废弃标记功能';
warnings.push(warning);
warnings.push('💡 建议:只在导入包含实际接口的规范时启用 markDeprecatedEndpoints');
console.error(`\n⚠️ 警告:新规范没有任何接口(paths 为空)`);
console.error(`⚠️ 启用 markDeprecatedEndpoints 时使用空规范会导致所有现有接口被标记为废弃`);
console.error(`⚠️ 已自动跳过废弃标记功能,直接导入空规范`);
console.error(`💡 建议:只在导入包含实际接口的规范时启用 markDeprecatedEndpoints\n`);
// 跳过废弃标记逻辑,直接使用原始 spec
finalSpec = spec;
// 执行导入(跳到 catch 块后)
const response = await this.client.post(
`/v1/projects/${this.projectId}/import-openapi`,
{
input: JSON.stringify(finalSpec),
options: {
endpointOverwriteBehavior: options?.endpointOverwriteBehavior || 'OVERWRITE_EXISTING',
schemaOverwriteBehavior: options?.schemaOverwriteBehavior || 'OVERWRITE_EXISTING',
updateFolderOfChangedEndpoint: options?.updateFolderOfChangedEndpoint ?? false,
prependBasePath: options?.prependBasePath ?? false,
...(options?.targetBranchId && { targetBranchId: options.targetBranchId })
}
}
);
return { ...response.data, _warnings: warnings };
}
// 提取所有新路径的公共前缀
const pathPrefixes = this.detectPathPrefixes(newPathKeys);
const scopeInfo = pathPrefixes.length > 0 ? pathPrefixes.join(', ') : '全部接口';
console.error(`\n🔍 检测到导入范围: ${scopeInfo}`);
console.error(`📊 新规范包含 ${newPathKeys.length} 个接口路径`);
// 3. 找出已删除的接口(只在相同路径前缀范围内对比)
const existingPaths = existingSpec.paths || {};
const deprecatedPaths: any = {};
// 计算范围内的现有接口总数
let existingPathsInScope = 0;
for (const path in existingPaths) {
const isInScope = pathPrefixes.length === 0 || pathPrefixes.some(prefix => path.startsWith(prefix));
if (isInScope) {
existingPathsInScope++;
}
}
for (const path in existingPaths) {
// 只对比在相同路径前缀范围内的接口
const isInScope = pathPrefixes.length === 0 || pathPrefixes.some(prefix => path.startsWith(prefix));
if (!isInScope) {
// 不在导入范围内,跳过
continue;
}
if (!newPaths[path]) {
// 整个路径被删除(在导入范围内)
deprecatedPaths[path] = { ...existingPaths[path] };
// 标记所有方法为废弃
for (const method in deprecatedPaths[path]) {
if (method !== 'parameters') {
deprecatedPaths[path][method] = {
...deprecatedPaths[path][method],
deprecated: true,
summary: `[已废弃] ${deprecatedPaths[path][method].summary || ''}`,
description: `⚠️ 此接口已废弃,不再维护。\n\n${deprecatedPaths[path][method].description || ''}`
};
}
}
} else {
// 路径存在,检查方法是否被删除
const existingMethods = Object.keys(existingPaths[path]).filter(m => m !== 'parameters');
const newMethods = Object.keys(newPaths[path]).filter(m => m !== 'parameters');
for (const method of existingMethods) {
if (!newMethods.includes(method)) {
// 方法被删除,标记为废弃
if (!deprecatedPaths[path]) {
deprecatedPaths[path] = {};
}
deprecatedPaths[path][method] = {
...existingPaths[path][method],
deprecated: true,
summary: `[已废弃] ${existingPaths[path][method].summary || ''}`,
description: `⚠️ 此接口已废弃,不再维护。\n\n${existingPaths[path][method].description || ''}`
};
}
}
}
}
// 4. 合并废弃接口到新规范
if (Object.keys(deprecatedPaths).length > 0) {
// 计算统计信息(使用范围内的接口数计算比例)
const deprecatedPathCount = Object.keys(deprecatedPaths).length;
const deprecatedRatio = existingPathsInScope > 0 ? (deprecatedPathCount / existingPathsInScope) * 100 : 0;
// 保存废弃信息用于返回
deprecatedInfo = {
count: deprecatedPathCount,
ratio: deprecatedRatio,
scope: pathPrefixes
};
// 安全检查:如果废弃比例过高,要求用户确认
if (deprecatedRatio > 50) {
// 如果没有传递确认参数,抛出错误阻止执行
if (!options?.confirmHighDeprecation) {
const errorMessage = [
`⚠️ 高比例废弃操作需要确认`,
``,
`检测到即将标记 ${deprecatedPathCount} 个接口为废弃,占范围内接口的 ${deprecatedRatio.toFixed(1)}%`,
`这个比例异常高,可能导致大量接口被误标记为废弃。`,
``,
`📊 详细信息:`,
` - 影响范围: ${pathPrefixes.length > 0 ? pathPrefixes.join(', ') : '全部接口'}`,
` - 范围内现有接口: ${existingPathsInScope} 个`,
` - 新规范接口数量: ${newPathKeys.length} 个`,
` - 即将废弃接口: ${deprecatedPathCount} 个`,
` - 废弃比例: ${deprecatedRatio.toFixed(1)}%`,
``,
`💡 可能原因:`,
` 1. 新规范的接口数量太少(当前仅 ${newPathKeys.length} 个)`,
` 2. 新规范的路径前缀与现有接口不匹配`,
` 3. 这是一次大规模重构,确实要废弃大量接口`,
``,
`❌ 操作已阻止,需要用户确认后才能继续`,
``,
`如需继续,请在 options 中设置 confirmHighDeprecation: true`
].join('\n');
throw new Error(errorMessage);
}
// 用户已确认,记录警告信息并继续执行
warnings.push(`⚠️ 高比例废弃警告:即将标记 ${deprecatedPathCount} 个接口为废弃(${deprecatedRatio.toFixed(1)}%)`);
warnings.push(`✅ 用户已确认,继续执行`);
console.error(`\n⚠️ 警告:即将标记 ${deprecatedPathCount} 个接口为废弃(占现有接口的 ${deprecatedRatio.toFixed(1)}%)`);
console.error(`✅ 用户已确认,继续执行\n`);
}
// 深度合并:保留新规范的所有内容,同时添加废弃的方法
const mergedPaths: any = { ...spec.paths };
for (const path in deprecatedPaths) {
if (!mergedPaths[path]) {
// 整个路径被删除,添加所有废弃方法
mergedPaths[path] = deprecatedPaths[path];
} else {
// 路径存在,只添加被删除的方法
mergedPaths[path] = {
...mergedPaths[path],
...deprecatedPaths[path]
};
}
}
finalSpec = {
...spec,
paths: mergedPaths
};
console.error(`\n🔖 标记了 ${deprecatedPathCount} 个废弃接口路径(${deprecatedRatio.toFixed(1)}%)`);
} else {
console.error(`\n✅ 没有接口被删除,无需标记废弃`);
}
} catch (error) {
// 如果获取现有规范失败(比如项目是空的),忽略错误继续导入
console.error(`⚠️ 无法获取现有规范来标记废弃接口: ${error instanceof Error ? error.message : String(error)}`);
}
}
// 执行导入
const response = await this.client.post(
`/v1/projects/${this.projectId}/import-openapi`,
{
input: JSON.stringify(finalSpec),
options: {
endpointOverwriteBehavior: options?.endpointOverwriteBehavior || 'OVERWRITE_EXISTING',
schemaOverwriteBehavior: options?.schemaOverwriteBehavior || 'OVERWRITE_EXISTING',
updateFolderOfChangedEndpoint: options?.updateFolderOfChangedEndpoint ?? false,
prependBasePath: options?.prependBasePath ?? false,
...(options?.targetBranchId && { targetBranchId: options.targetBranchId })
}
}
);
// 附加警告信息和废弃统计到返回值
return {
...response.data,
_warnings: warnings.length > 0 ? warnings : undefined,
_deprecatedInfo: deprecatedInfo
};
}
/**
* 导出 Apifox 项目为 OpenAPI/Swagger 规范
*
* @param options 导出选项
* @returns OpenAPI 规范对象
*/
async exportOpenApi(options?: {
oasVersion?: '2.0' | '3.0' | '3.1';
exportFormat?: 'JSON' | 'YAML';
scope?: {
type?: 'ALL' | 'SELECTED_FOLDERS' | 'SELECTED_ENDPOINTS';
excludedByTags?: string[];
};
options?: {
includeApifoxExtensionProperties?: boolean;
addFoldersToTags?: boolean;
};
}): Promise<any> {
const response = await this.client.post(
`/v1/projects/${this.projectId}/export-openapi`,
{
oasVersion: options?.oasVersion || '3.0',
exportFormat: options?.exportFormat || 'JSON',
...(options?.scope && { scope: options.scope }),
...(options?.options && { options: options.options })
}
);
return response.data;
}
}