whistle.mock-plugins
Version:
Whistle 插件,用于快速创建 API 模拟数据
583 lines (497 loc) • 19.6 kB
JavaScript
const fs = require('fs');
const path = require('path');
const url = require('url');
const Mock = require('mockjs');
/**
* 规则管理模块,负责处理 Whistle 规则和代理请求
*/
const ruleManager = {
/**
* 初始化规则管理器
* @param {object} options 选项对象
* @param {object} options.server Whistle 服务器对象
* @param {object} options.config Whistle 配置对象
* @param {object} options.dataManager 数据管理器对象
*/
init(options) {
this.server = options.server;
this.config = options.config;
this.dataManager = options.dataManager;
this.baseDir = options.config.baseDir;
// 设置日志函数
this.log = options.log || console.log;
this.log('规则管理器初始化完成, baseDir: ' + this.baseDir);
// 确保 mock 数据目录存在
this.mockDataDir = path.join(this.baseDir, 'mock-data');
if (!fs.existsSync(this.mockDataDir)) {
fs.mkdirSync(this.mockDataDir, { recursive: true });
this.log('创建mock数据目录: ' + this.mockDataDir);
}
},
/**
* 处理代理请求
* @param {object} req 请求对象
* @param {object} res 响应对象
* @param {string} ruleValue 规则值
* @param {function} next 下一个中间件
* @returns {Promise<object>} 处理结果
*/
async handleRequest(req, res, ruleValue, next) {
// 只记录请求开始和规则值
this.log(`[规则处理器] 收到请求: ${req.method} ${req.url}, 规则值: ${ruleValue || '无'}`);
// 获取所有启用的接口配置
const enabledInterfaces = await this.dataManager.getEnabledInterfaces();
// 只记录接口数量,不记录详情
this.log(`[规则处理器] 启用的接口数量: ${enabledInterfaces ? enabledInterfaces.length : 0}`);
if (!enabledInterfaces || enabledInterfaces.length === 0) {
this.log('[规则处理器] 没有启用的接口可用,透传请求');
return { handled: false };
}
// 解析请求路径
const parsedUrl = url.parse(req.url);
const requestPath = parsedUrl.pathname;
// 查找匹配的接口
const matchedInterface = this.findMatchingInterface(enabledInterfaces, requestPath, req.method);
if (!matchedInterface) {
this.log(`[规则处理器] 未找到匹配的接口,请求将透传: ${req.method} ${requestPath}`);
// 从请求头获取原始完整URL
const originalUrl = req.originalReq?.url || req.originalReq?.realUrl;
// 如果存在原始URL,确保放回请求头,以便后续处理程序使用
if (originalUrl && originalUrl.startsWith('http')) {
this.log(`[规则处理器] 保存原始URL到请求头,确保透传: ${originalUrl}`);
req.headers['x-whistle-real-url'] = originalUrl;
}
// 标记为未处理,将由下一个中间件处理
return { handled: false };
}
this.log(`[规则处理器] 找到匹配的接口: ${matchedInterface.name}, 代理类型: ${matchedInterface.proxyType}`);
// 根据接口类型处理请求
this.log(`[规则处理器] 开始处理请求...`);
const result = await this.processRequest(matchedInterface, req, res);
this.log(`[规则处理器] 请求处理完成,状态: ${result.error ? '失败' : '成功'}`);
return { handled: true, result };
},
/**
* 根据请求路径和方法查找匹配的接口
* @param {Array} interfaces 接口列表
* @param {string} requestPath 请求路径
* @param {string} method 请求方法
* @returns {object|null} 匹配的接口对象,如果没有匹配则返回 null
*/
findMatchingInterface(interfaces, requestPath, method) {
// 简化日志,只记录正在进行匹配的操作
this.log(`[规则处理器] 尝试匹配接口,路径: ${requestPath}, 方法: ${method}`);
// 确保interfaces是一个数组
if (!Array.isArray(interfaces)) {
this.log(`[规则处理器] 错误: interfaces不是一个数组`);
return null;
}
// 防止空路径
if (!requestPath) {
this.log(`[规则处理器] 错误: 请求路径为空`);
return null;
}
// 先尝试完全匹配,效率更高
let matchedInterface = interfaces.find(intf => {
// 检查接口对象是否有效
if (!intf || !intf.urlPattern) {
return false;
}
// 检查URL和方法是否匹配
const urlMatches = intf.urlPattern === requestPath;
// 修复: 兼容不同的方法名称和值
const methodField = intf.httpMethod || intf.method;
const methodValue = methodField && methodField.toUpperCase();
const methodMatches = !methodValue ||
methodValue === 'ALL' ||
methodValue === 'ANY' ||
methodValue === method;
// 移除中间匹配过程的日志
return urlMatches && methodMatches;
});
if (matchedInterface) {
this.log(`[规则处理器] 找到完全匹配的接口: ${matchedInterface.name}`);
return matchedInterface;
}
// 如果没有完全匹配,尝试正则表达式匹配
this.log(`[规则处理器] 未找到完全匹配,尝试正则表达式匹配...`);
// 使用正则表达式缓存优化性能
if (!this.regexCache) {
this.regexCache = new Map();
}
try {
matchedInterface = interfaces.find(intf => {
try {
// 验证接口对象
if (!intf || !intf.urlPattern) {
return false;
}
// 从缓存获取或创建正则表达式
let regex;
if (this.regexCache.has(intf.urlPattern)) {
regex = this.regexCache.get(intf.urlPattern);
} else {
// 将通配符转换为正则表达式
const pattern = this.convertPatternToRegex(intf.urlPattern);
regex = new RegExp(pattern);
// 缓存正则表达式,避免重复创建
this.regexCache.set(intf.urlPattern, regex);
}
// 获取方法值,兼容不同的字段名
const methodField = intf.httpMethod || intf.method;
const methodValue = methodField && methodField.toUpperCase();
// 检查URL和方法是否匹配
const urlMatches = regex.test(requestPath);
const methodMatches = !methodValue ||
methodValue === 'ALL' ||
methodValue === 'ANY' ||
methodValue === method;
return urlMatches && methodMatches;
} catch (err) {
// 记录特定接口的错误,但继续处理其他接口
this.log(`[规则处理器] 匹配接口 ${intf?.name || 'unknown'} 时出错: ${err.message}`);
return false;
}
});
} catch (err) {
// 记录整体匹配过程的错误
this.log(`[规则处理器] 正则匹配过程出错: ${err.message}`);
return null;
}
if (matchedInterface) {
this.log(`[规则处理器] 找到正则匹配的接口: ${matchedInterface.name}`);
} else {
this.log(`[规则处理器] 未找到匹配的接口`);
}
return matchedInterface;
},
/**
* 将URL模式中的通配符转换为正则表达式
* @param {string} pattern URL模式
* @returns {string} 正则表达式字符串
*/
convertPatternToRegex(pattern) {
if (pattern.startsWith('/') && pattern.endsWith('/') && pattern.length > 2) {
// 已经是正则表达式格式:/pattern/
return pattern.slice(1, -1);
}
// 转换通配符 * 为 .*
return pattern.replace(/\*/g, '.*').replace(/\//g, '\\/');
},
/**
* 根据接口类型处理请求
* @param {object} interfaceObj 接口对象
* @param {object} req 请求对象
* @param {object} res 响应对象
* @returns {Promise<object>} 处理结果
*/
async processRequest(interfaceObj, req, res) {
// 兼容不同的代理类型字段名和值
const proxyType = interfaceObj.proxyType || '';
// 创建统一的配置对象,兼容直接字段访问和config嵌套字段
const config = interfaceObj.config || interfaceObj;
// 只记录关键信息
this.log(`[规则处理器] 处理请求,接口: ${interfaceObj.name}, 代理类型: ${proxyType}`);
// 设置响应头
let headers = config.headers;
if (typeof headers === 'string') {
headers = this.parseHeaders(headers);
} else if (typeof headers === 'object' && headers !== null) {
// 已经是对象格式,直接使用
} else {
headers = {};
}
Object.entries(headers).forEach(([key, value]) => {
res.setHeader(key, value);
});
// 设置内容类型
if (config.contentType && !res.getHeader('Content-Type')) {
res.setHeader('Content-Type', config.contentType);
}
// 添加延迟
const delay = parseInt(config.responseDelay || config.delay || 0, 10);
if (delay > 0) {
this.log(`[规则处理器] 添加响应延迟: ${delay}ms`);
await new Promise(resolve => setTimeout(resolve, delay));
}
// 设置状态码
res.statusCode = parseInt(config.httpStatus || config.statusCode || 200, 10);
// 根据代理类型处理
let result;
if (proxyType === 'url_redirect' || proxyType === 'redirect') {
result = await this.handleUrlRedirect(interfaceObj, req, res);
}
else if (proxyType === 'data_template' || proxyType === 'response') {
result = await this.handleDataTemplate(interfaceObj, req, res);
}
else if (proxyType === 'file_proxy' || proxyType === 'file') {
result = await this.handleFileProxy(interfaceObj, req, res);
}
else if (proxyType === 'dynamic_data') {
result = await this.handleDynamicData(interfaceObj, req, res);
}
else {
// 默认使用数据模板处理
this.log(`[规则处理器] 未知的代理类型 "${proxyType}",默认使用数据模板处理`);
result = await this.handleDataTemplate(interfaceObj, req, res);
}
// 记录处理结果
this.log(`[规则处理器] 请求处理完成,状态码: ${res.statusCode}`);
// 记录完整的命中规则日志
if (this.dataManager && typeof this.dataManager.logRequest === 'function') {
const parsedUrl = url.parse(req.url);
try {
this.dataManager.logRequest({
type: 'mock_hit',
eventType: 'mock_hit',
message: `命中接口规则: ${interfaceObj.name}`,
method: req.method,
url: req.url,
path: parsedUrl.pathname,
status: res.statusCode,
pattern: interfaceObj.urlPattern,
proxyType: proxyType,
interfaceName: interfaceObj.name,
interfaceId: interfaceObj.id,
responseData: result,
responseTime: new Date().toISOString(),
contentType: res.getHeader('Content-Type') || config.contentType
});
} catch (err) {
this.log(`[规则处理器] 记录命中规则日志失败: ${err.message}`);
}
}
return result;
},
/**
* 处理 URL 重定向类型
* @param {object} interfaceObj 接口对象
* @param {object} req 请求对象
* @param {object} res 响应对象
* @returns {Promise<object>} 处理结果
*/
async handleUrlRedirect(interfaceObj, req, res) {
// 兼容不同的字段命名
const config = interfaceObj.config || interfaceObj;
const targetUrl = config.targetUrl || '';
const preserveParams = config.preserveParams || false;
if (!targetUrl) {
res.end(JSON.stringify({ error: 'No target URL specified' }));
return { error: 'No target URL specified' };
}
// 构建重定向URL
let redirectUrl = targetUrl;
// 如果需要保留参数,则将原请求的查询参数附加到重定向URL
if (preserveParams) {
const parsedUrl = url.parse(req.url, true);
const parsedTargetUrl = url.parse(targetUrl, true);
// 合并查询参数
const mergedQuery = { ...parsedTargetUrl.query, ...parsedUrl.query };
// 重建URL
const redirectUrlObj = new url.URL(targetUrl);
Object.entries(mergedQuery).forEach(([key, value]) => {
redirectUrlObj.searchParams.set(key, value);
});
redirectUrl = redirectUrlObj.toString();
}
// 执行重定向
res.setHeader('Location', redirectUrl);
res.statusCode = 302;
res.end();
return { redirectUrl };
},
/**
* 处理数据模板类型
* @param {object} interfaceObj 接口对象
* @param {object} req 请求对象
* @param {object} res 响应对象
* @returns {Promise<object>} 处理结果
*/
async handleDataTemplate(interfaceObj, req, res) {
// 兼容不同的字段命名
const config = interfaceObj.config || interfaceObj;
const template = config.responseContent || config.template || '{}';
if (!template) {
res.end(JSON.stringify({ error: 'No template specified' }));
return { error: 'No template specified' };
}
try {
// 使用 Mock.js 生成数据
const mockData = Mock.mock(JSON.parse(template));
// 设置内容类型
if (!res.getHeader('Content-Type')) {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
}
// 发送数据
res.end(JSON.stringify(mockData));
return { mockData };
} catch (err) {
res.end(JSON.stringify({ error: 'Template parsing error', message: err.message }));
return { error: 'Template parsing error', message: err.message };
}
},
/**
* 处理文件代理类型
* @param {object} interfaceObj 接口对象
* @param {object} req 请求对象
* @param {object} res 响应对象
* @returns {Promise<object>} 处理结果
*/
async handleFileProxy(interfaceObj, req, res) {
// 兼容不同的字段命名
const config = interfaceObj.config || interfaceObj;
const filePath = config.filePath || '';
// 只记录关键信息
this.log(`[规则处理器] 处理文件代理,路径: ${filePath}`);
if (!filePath) {
this.log(`[规则处理器] 错误: 未指定文件路径`);
res.end(JSON.stringify({ error: 'No file path specified' }));
return { error: 'No file path specified' };
}
// 确定文件路径
const absFilePath = path.isAbsolute(filePath)
? filePath
: path.join(this.mockDataDir, filePath);
try {
// 检查文件是否存在
if (!fs.existsSync(absFilePath)) {
this.log(`[规则处理器] 错误: 文件不存在: ${path.basename(absFilePath)}`);
res.statusCode = 404;
res.end(JSON.stringify({ error: 'File not found', path: absFilePath }));
return { error: 'File not found', path: absFilePath };
}
// 读取文件内容
const fileContent = fs.readFileSync(absFilePath, 'utf8');
this.log(`[规则处理器] 成功读取文件: ${path.basename(absFilePath)}`);
// 设置内容类型(根据文件扩展名)
if (!res.getHeader('Content-Type')) {
const ext = path.extname(absFilePath).toLowerCase();
const contentType = this.getContentTypeByExt(ext);
if (contentType) {
res.setHeader('Content-Type', contentType);
}
}
// 设置响应头,标识插件处理
res.setHeader('X-Handled-By', 'whistle.mock-plugin');
// 发送文件内容
res.end(fileContent);
return { filePath: absFilePath, fileName: path.basename(absFilePath) };
} catch (err) {
this.log(`[规则处理器] 读取文件错误: ${err.message}`);
res.end(JSON.stringify({ error: 'File reading error', message: err.message }));
return { error: 'File reading error', message: err.message };
}
},
/**
* 处理动态数据生成类型
* @param {object} interfaceObj 接口对象
* @param {object} req 请求对象
* @param {object} res 响应对象
* @returns {Promise<object>} 处理结果
*/
async handleDynamicData(interfaceObj, req, res) {
const { config } = interfaceObj;
const { script } = config;
if (!script) {
res.end(JSON.stringify({ error: 'No script specified' }));
return { error: 'No script specified' };
}
try {
// 创建执行环境
const context = {
req,
Mock,
url: req.url,
method: req.method,
headers: req.headers,
query: url.parse(req.url, true).query,
};
// 从请求中读取 body
if (req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH') {
const body = await this.readRequestBody(req);
context.body = body;
// 尝试解析 JSON
try {
context.bodyJson = JSON.parse(body);
} catch (e) {
// 忽略解析错误
}
}
// 执行脚本
const fn = new Function('ctx', `
with (ctx) {
${script}
return typeof result !== 'undefined' ? result : {};
}
`);
const result = fn(context);
// 设置内容类型
if (!res.getHeader('Content-Type')) {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
}
// 发送结果
res.end(typeof result === 'string' ? result : JSON.stringify(result));
return { result };
} catch (err) {
res.end(JSON.stringify({ error: 'Script execution error', message: err.message }));
return { error: 'Script execution error', message: err.message };
}
},
/**
* 从请求中读取 body
* @param {object} req 请求对象
* @returns {Promise<string>} 请求体字符串
*/
readRequestBody(req) {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', chunk => {
body += chunk.toString();
});
req.on('end', () => {
resolve(body);
});
req.on('error', err => {
reject(err);
});
});
},
/**
* 解析响应头字符串
* @param {string} headersStr 响应头字符串
* @returns {object} 解析后的响应头对象
*/
parseHeaders(headersStr) {
if (!headersStr) return {};
const headers = {};
headersStr.split('\n').forEach(line => {
const [key, ...valueParts] = line.split(':');
if (key && valueParts.length > 0) {
const value = valueParts.join(':').trim();
if (value) {
headers[key.trim()] = value;
}
}
});
return headers;
},
/**
* 根据文件扩展名获取内容类型
* @param {string} ext 文件扩展名
* @returns {string|null} 内容类型
*/
getContentTypeByExt(ext) {
const contentTypes = {
'.json': 'application/json; charset=utf-8',
'.xml': 'application/xml; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.htm': 'text/html; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.csv': 'text/csv; charset=utf-8',
};
return contentTypes[ext] || null;
}
};
module.exports = ruleManager;