@paultaku/node-mock-server
Version:
A TypeScript-based mock server with automatic Swagger-based mock file generation
570 lines (547 loc) • 22.4 kB
JavaScript
/******/ (() => { // webpackBootstrap
/******/ "use strict";
/******/ var __webpack_modules__ = ({
/***/ 252:
/***/ ((module) => {
module.exports = require("express");
/***/ }),
/***/ 422:
/***/ (function(module, exports, __webpack_require__) {
/* module decorator */ module = __webpack_require__.nmd(module);
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.startMockServer = startMockServer;
const express_1 = __importDefault(__webpack_require__(252));
const path_1 = __importDefault(__webpack_require__(928));
const fs_extra_1 = __importDefault(__webpack_require__(652));
const zod_1 = __webpack_require__(569);
const status_manager_1 = __webpack_require__(537);
const DEFAULT_MOCK_ROOT = path_1.default.resolve(__dirname, "../mock");
const DEFAULT_MOCK_FILE = "successful-operation-200.json";
// Store the mock response state of each endpoint
let mockStates = new Map();
// Only allow letters, numbers, -, _, {}, not : * ? ( ) [ ] etc.
function isValidMockPart(part) {
return /^[a-zA-Z0-9_\-{}]+$/.test(part);
}
// Get all mock endpoint templates (e.g. user/{username}/GET)
async function getAllMockTemplates(mockRoot) {
async function walk(dir, parts = []) {
const entries = await fs_extra_1.default.readdir(dir);
let results = [];
for (const entry of entries) {
if (!isValidMockPart(entry))
continue; // Skip invalid names
const fullPath = path_1.default.join(dir, entry);
const stat = await fs_extra_1.default.stat(fullPath);
if (stat.isDirectory()) {
// Check if there are json files under this directory (i.e. method directory)
const files = await fs_extra_1.default.readdir(fullPath);
const jsonFiles = files.filter((f) => f.endsWith(".json") && f !== "status.json");
if (jsonFiles.length > 0) {
// This is a method directory, push parts+method
results.push([...parts, entry]);
}
// Continue recursion
results = results.concat(await walk(fullPath, [...parts, entry]));
}
}
return results;
}
return walk(mockRoot);
}
// Path parameter template matching
function matchTemplate(requestParts, templates, method) {
let bestMatch = null;
for (const tpl of templates) {
if (tpl.length !== requestParts.length + 1)
continue; // +1 for method
const lastElement = tpl[tpl.length - 1];
if (!lastElement || lastElement.toUpperCase() !== method)
continue;
let params = {};
let matched = true;
for (let i = 0; i < requestParts.length; i++) {
const tplElement = tpl[i];
if (!tplElement)
continue;
if (tplElement.startsWith("{") && tplElement.endsWith("}")) {
params[tplElement.slice(1, -1)] = requestParts[i] || "";
}
else if (tplElement !== requestParts[i]) {
matched = false;
break;
}
}
if (matched) {
bestMatch = { template: tpl, params };
break;
}
}
return bestMatch;
}
// Get all available mock files for an endpoint
async function getAvailableMockFiles(endpointDir) {
try {
const files = await fs_extra_1.default.readdir(endpointDir);
return files.filter((file) => file.endsWith(".json") && file !== "status.json");
}
catch (error) {
return [];
}
}
// Get the mock state key for the current endpoint
function getMockStateKey(path, method) {
return `${method.toUpperCase()}:${path}`;
}
// Create Express app
function createApp(mockRoot = DEFAULT_MOCK_ROOT) {
const app = (0, express_1.default)();
// Middleware
app.use(express_1.default.json());
app.use(express_1.default.static(path_1.default.join(__dirname, "./public")));
// Load all status.json files when service starts
(async () => {
const templates = await getAllMockTemplates(mockRoot);
mockStates = await (0, status_manager_1.loadAllStatusJson)(mockRoot, templates);
console.log("[status-manager] All status.json files loaded");
})();
// API endpoint: get all available endpoints
app.get("/_mock/endpoints", async (req, res) => {
try {
console.log("Getting all mock templates...");
const templates = await getAllMockTemplates(mockRoot);
console.log("Templates found:", templates);
const endpoints = [];
for (const template of templates) {
const method = template[template.length - 1] || "";
const pathParts = template.slice(0, -1);
const apiPath = "/" + pathParts.join("/");
const stateKey = getMockStateKey(apiPath, method);
const currentMock = mockStates.get(stateKey) || DEFAULT_MOCK_FILE;
// Read status.json to get delayMillisecond
const statusPath = (0, status_manager_1.getStatusJsonPath)(mockRoot, apiPath, method);
let delayMillisecond = undefined;
try {
const status = await (0, status_manager_1.readStatusJson)(statusPath);
if (status && typeof status.delayMillisecond === "number") {
delayMillisecond = status.delayMillisecond;
}
}
catch { }
endpoints.push({
path: apiPath,
method: method,
currentMock: currentMock,
availableMocks: [], // Explicit type
delayMillisecond,
});
}
// Get available mock files for each endpoint
for (const endpoint of endpoints) {
const endpointDir = path_1.default.join(mockRoot, ...endpoint.path.replace(/^\//, "").split("/"), endpoint.method);
endpoint.availableMocks = await getAvailableMockFiles(endpointDir);
}
res.json(endpoints);
}
catch (error) {
res
.status(500)
.json({ error: "Failed to get endpoints", detail: String(error) });
}
});
// API endpoint: set the mock response for an endpoint
const SetMockRequestSchema = zod_1.z.object({
path: zod_1.z.string(),
method: zod_1.z.string(),
mockFile: zod_1.z.string(),
});
// API endpoint: update mock status via /_mock URL segment
app.post("/_mock/update", async (req, res) => {
try {
const { path: apiPath, method, mockFile, delayMillisecond } = req.body;
if (!apiPath || !method) {
return res
.status(400)
.json({ error: "Missing required parameters: path and method" });
}
const statusPath = (0, status_manager_1.getStatusJsonPath)(mockRoot, apiPath, method);
let status = await (0, status_manager_1.readStatusJson)(statusPath);
if (!status)
status = { selected: DEFAULT_MOCK_FILE };
// Update mock file if provided
if (mockFile) {
// Validate if the mock file exists
const endpointDir = path_1.default.join(mockRoot, ...apiPath.replace(/^\//, "").split("/"), method.toUpperCase());
const mockFilePath = path_1.default.join(endpointDir, mockFile);
if (!(await fs_extra_1.default.pathExists(mockFilePath))) {
return res
.status(400)
.json({ error: "Mock file not found", file: mockFilePath });
}
status.selected = mockFile;
const stateKey = getMockStateKey(apiPath, method);
mockStates.set(stateKey, mockFile);
}
// Update delay if provided
if (typeof delayMillisecond === "number") {
if (delayMillisecond < 0 || delayMillisecond > 60000) {
return res
.status(400)
.json({ error: "Delay must be between 0 and 60000 milliseconds" });
}
status.delayMillisecond = delayMillisecond;
}
// Write updated status
await fs_extra_1.default.writeJson(statusPath, status, { spaces: 2 });
console.log(`[status-manager] Updated ${statusPath} via /_mock/update`);
return res.json({
success: true,
message: "Mock status updated successfully",
status: {
selected: status.selected,
delayMillisecond: status.delayMillisecond,
},
});
}
catch (error) {
return res
.status(500)
.json({ error: "Failed to update mock status", detail: String(error) });
}
});
// API endpoint: get mock status via /_mock URL segment
app.get("/_mock/status", async (req, res) => {
try {
const { method, path: apiPath } = req.query;
if (!method ||
!apiPath ||
typeof method !== "string" ||
typeof apiPath !== "string") {
return res
.status(400)
.json({ error: "Missing method or path parameter" });
}
const statusPath = (0, status_manager_1.getStatusJsonPath)(mockRoot, apiPath, method);
const status = await (0, status_manager_1.readStatusJson)(statusPath);
const stateKey = getMockStateKey(apiPath, method);
const currentMock = mockStates.get(stateKey) || DEFAULT_MOCK_FILE;
return res.json({
path: apiPath,
method: method,
currentMock: status?.selected || currentMock,
delayMillisecond: status?.delayMillisecond || 0,
});
}
catch (error) {
return res
.status(500)
.json({ error: "Failed to get mock status", detail: String(error) });
}
});
// New: API for setting delay
// 1. /api/set-delay validation and writing
const SetDelayRequestSchema = zod_1.z.object({
path: zod_1.z.string(),
method: zod_1.z.string(),
delayMillisecond: zod_1.z.number().min(0).max(60000),
});
app.post("/_mock/set-delay", async (req, res) => {
try {
const { path: apiPath, method, delayMillisecond, } = SetDelayRequestSchema.parse(req.body);
const statusPath = (0, status_manager_1.getStatusJsonPath)(mockRoot, apiPath, method);
let status = await (0, status_manager_1.readStatusJson)(statusPath);
if (!status)
status = { selected: DEFAULT_MOCK_FILE };
await fs_extra_1.default.writeJson(statusPath, { ...status, delayMillisecond }, { spaces: 2 });
console.log(`[status-manager] Set delay ${delayMillisecond}ms for ${statusPath}`);
return res.json({
success: true,
message: `Delay set to ${delayMillisecond}ms`,
});
}
catch (error) {
if (error instanceof zod_1.z.ZodError) {
return res
.status(400)
.json({ error: "Invalid request data", details: error.errors });
}
else {
return res
.status(500)
.json({ error: "Failed to set delay", detail: String(error) });
}
}
});
// The main mock server logic
app.use(async (req, res, next) => {
try {
const reqPath = req.path.replace(/^\//, "");
const method = req.method.toUpperCase();
const requestParts = reqPath ? reqPath.split("/") : [];
// Skip API endpoints
if (reqPath.startsWith("api/")) {
return next();
}
const templates = await getAllMockTemplates(mockRoot);
const match = matchTemplate(requestParts, templates, method);
let endpointDir;
let apiPath;
if (match) {
endpointDir = path_1.default.join(mockRoot, ...match.template);
apiPath = "/" + match.template.slice(0, -1).join("/");
}
else {
// fallback: exact path
endpointDir = path_1.default.join(mockRoot, ...requestParts, method);
apiPath = "/" + requestParts.join("/");
}
// Select mock response file
const stateKey = getMockStateKey(apiPath, method);
let mockFile = mockStates.get(stateKey);
if (!mockFile) {
// fallback: read status.json or default
const statusPath = (0, status_manager_1.getStatusJsonPath)(mockRoot, apiPath, method);
const status = await (0, status_manager_1.readStatusJson)(statusPath);
if (status && status.selected) {
mockFile = status.selected;
mockStates.set(stateKey, mockFile);
console.log(`[status-manager] fallback read ${statusPath} -> ${mockFile}`);
}
else {
mockFile = DEFAULT_MOCK_FILE;
mockStates.set(stateKey, mockFile);
console.log(`[status-manager] fallback default ${stateKey} -> ${mockFile}`);
}
}
const filePath = path_1.default.join(endpointDir, mockFile);
if (!(await fs_extra_1.default.pathExists(filePath))) {
return res.status(404).json({
error: "Mock file not found",
file: filePath,
availableFiles: await getAvailableMockFiles(endpointDir),
});
}
const mock = await fs_extra_1.default.readJson(filePath);
// Set headers
if (Array.isArray(mock.header)) {
for (const h of mock.header) {
if (h && h.key && h.value) {
res.setHeader(h.key, h.value);
}
}
}
// Set status code
const statusMatch = mockFile.match(/-(\d+)\.json$/);
if (statusMatch && statusMatch[1]) {
res.status(parseInt(statusMatch[1]));
}
// Read status.json to get delayMillisecond
const statusPath = (0, status_manager_1.getStatusJsonPath)(mockRoot, apiPath, method);
const status = await (0, status_manager_1.readStatusJson)(statusPath);
let delayMillisecond = 0;
if (status) {
if (typeof status.delayMillisecond === "number" &&
status.delayMillisecond > 0) {
delayMillisecond = status.delayMillisecond;
}
else if (typeof status.delayMillisecond === "number" &&
status.delayMillisecond > 0) {
// Compatible with old field
delayMillisecond = status.delayMillisecond * 1000;
}
if (delayMillisecond > 0) {
console.log(`[status-manager] ${apiPath} ${method} delay ${delayMillisecond}ms`);
}
}
// Delay response
if (delayMillisecond > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMillisecond));
}
return res.json(mock.body);
}
catch (error) {
return res
.status(500)
.json({ error: "Mock server error", detail: String(error) });
}
});
// 404 handling
app.use((req, res) => {
res.status(404).json({ error: "API endpoint not found" });
});
return app;
}
// Function to start the server
function startMockServer(port = 3001, mockRoot) {
const resolvedMockRoot = mockRoot
? path_1.default.resolve(mockRoot)
: DEFAULT_MOCK_ROOT;
return new Promise((resolve, reject) => {
try {
const app = createApp(resolvedMockRoot);
const server = app.listen(port, () => {
console.log(`Mock server running at http://localhost:${port}`);
console.log(`API endpoints available at http://localhost:${port}/_mock/endpoints`);
console.log(`Mock root directory: ${resolvedMockRoot}`);
resolve();
});
server.on("error", (error) => {
reject(error);
});
}
catch (error) {
reject(error);
}
});
}
// If this file is run directly, start the server
if (__webpack_require__.c[__webpack_require__.s] === module) {
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3001;
const MOCK_ROOT = process.env.MOCK_ROOT;
startMockServer(PORT, MOCK_ROOT).catch((error) => {
console.error("Failed to start mock server:", error);
process.exit(1);
});
}
/***/ }),
/***/ 537:
/***/ (function(__unused_webpack_module, exports, __webpack_require__) {
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.getStatusJsonPath = getStatusJsonPath;
exports.readStatusJson = readStatusJson;
exports.writeStatusJson = writeStatusJson;
exports.loadAllStatusJson = loadAllStatusJson;
const fs_extra_1 = __importDefault(__webpack_require__(652));
const path_1 = __importDefault(__webpack_require__(928));
const DEFAULT_MOCK_FILE = "successful-operation-200.json";
/**
* 获取 status.json 路径
* @param mockRoot mock 根目录
* @param endpointPath 形如 /pet/{petId}
* @param method HTTP 方法(GET/POST/...)
*/
function getStatusJsonPath(mockRoot, endpointPath, method) {
return path_1.default.join(mockRoot, ...endpointPath.replace(/^\//, "").split("/"), method.toUpperCase(), "status.json");
}
/**
* 读取 status.json
* @param statusPath status.json 文件路径
* @returns StatusJson 对象或 null
*/
async function readStatusJson(statusPath) {
try {
const data = await fs_extra_1.default.readJson(statusPath);
if (typeof data.selected === "string" && data.selected.endsWith(".json")) {
return data;
}
return null;
}
catch {
return null;
}
}
/**
* 写入 status.json(原子写入)
* @param statusPath status.json 文件路径
* @param selected 选中的 mock 文件名
*/
async function writeStatusJson(statusPath, selected) {
await fs_extra_1.default.writeJson(statusPath, { selected }, { spaces: 2 });
}
/**
* 初始化所有 endpoint 的 mock 选择状态
* @param mockRoot mock 根目录
* @param templates 所有 endpoint 模板数组
* @returns Map<stateKey, selectedMockFile>
*/
async function loadAllStatusJson(mockRoot, templates) {
const stateMap = new Map();
for (const template of templates) {
const method = template[template.length - 1] || "";
const endpointPath = "/" + template.slice(0, -1).join("/");
if (!method)
continue; // 跳过无效 method
const statusPath = getStatusJsonPath(mockRoot, endpointPath, method);
const status = await readStatusJson(statusPath);
if (status && status.selected) {
stateMap.set(`${method.toUpperCase()}:${endpointPath}`, status.selected);
}
else {
// fallback
stateMap.set(`${method.toUpperCase()}:${endpointPath}`, DEFAULT_MOCK_FILE);
}
}
return stateMap;
}
/***/ }),
/***/ 569:
/***/ ((module) => {
module.exports = require("zod");
/***/ }),
/***/ 652:
/***/ ((module) => {
module.exports = require("fs-extra");
/***/ }),
/***/ 928:
/***/ ((module) => {
module.exports = require("path");
/***/ })
/******/ });
/************************************************************************/
/******/ // The module cache
/******/ var __webpack_module_cache__ = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/ // Check if module is in cache
/******/ var cachedModule = __webpack_module_cache__[moduleId];
/******/ if (cachedModule !== undefined) {
/******/ return cachedModule.exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = __webpack_module_cache__[moduleId] = {
/******/ id: moduleId,
/******/ loaded: false,
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ __webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/ // Flag the module as loaded
/******/ module.loaded = true;
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/******/ // expose the module cache
/******/ __webpack_require__.c = __webpack_module_cache__;
/******/
/************************************************************************/
/******/ /* webpack/runtime/node module decorator */
/******/ (() => {
/******/ __webpack_require__.nmd = (module) => {
/******/ module.paths = [];
/******/ if (!module.children) module.children = [];
/******/ return module;
/******/ };
/******/ })();
/******/
/************************************************************************/
/******/
/******/ // module cache are used so entry inlining is disabled
/******/ // startup
/******/ // Load entry module and return exports
/******/ var __webpack_exports__ = __webpack_require__(__webpack_require__.s = 422);
/******/ module.exports = __webpack_exports__;
/******/
/******/ })()
;
//# sourceMappingURL=server.js.map