api-json-server 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +636 -0
- package/dist/behavior.js +44 -0
- package/dist/history/historyRecorder.js +66 -0
- package/dist/history/types.js +2 -0
- package/dist/index.js +126 -5
- package/dist/loadSpec.js +32 -0
- package/dist/logger/customLogger.js +75 -0
- package/dist/logger/formatters.js +82 -0
- package/dist/logger/types.js +2 -0
- package/dist/openapi.js +152 -0
- package/dist/registerEndpoints.js +97 -0
- package/dist/requestMatch.js +99 -0
- package/dist/responseRenderer.js +98 -0
- package/dist/server.js +210 -0
- package/dist/spec.js +146 -0
- package/dist/stringTemplate.js +55 -0
- package/examples/auth-variants.json +31 -0
- package/examples/basic-crud.json +46 -0
- package/examples/companies-nested.json +47 -0
- package/examples/orders-and-matches.json +49 -0
- package/examples/users-faker.json +35 -0
- package/mock.spec.json +1 -0
- package/mockserve.spec.schema.json +7 -0
- package/package.json +20 -3
- package/scripts/build-schema.ts +21 -0
- package/src/behavior.ts +56 -0
- package/src/history/historyRecorder.ts +77 -0
- package/src/history/types.ts +25 -0
- package/src/index.ts +124 -85
- package/src/loadSpec.ts +5 -2
- package/src/logger/customLogger.ts +85 -0
- package/src/logger/formatters.ts +74 -0
- package/src/logger/types.ts +30 -0
- package/src/openapi.ts +203 -0
- package/src/registerEndpoints.ts +94 -162
- package/src/requestMatch.ts +104 -0
- package/src/responseRenderer.ts +112 -0
- package/src/server.ts +236 -14
- package/src/spec.ts +108 -8
- package/src/stringTemplate.ts +55 -0
- package/tests/behavior.test.ts +88 -0
- package/tests/cors.test.ts +128 -0
- package/tests/faker.test.ts +175 -0
- package/tests/fixtures/spec.basic.json +39 -0
- package/tests/headers.test.ts +124 -0
- package/tests/helpers.ts +28 -0
- package/tests/history.test.ts +188 -0
- package/tests/matching.test.ts +245 -0
- package/tests/server.test.ts +73 -0
- package/tests/template.test.ts +90 -0
- package/src/template.ts +0 -61
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HistoryRecorder = void 0;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
/**
|
|
6
|
+
* In-memory request history recorder.
|
|
7
|
+
*/
|
|
8
|
+
class HistoryRecorder {
|
|
9
|
+
entries = [];
|
|
10
|
+
maxEntries;
|
|
11
|
+
/**
|
|
12
|
+
* Create a new history recorder.
|
|
13
|
+
* @param maxEntries Maximum number of entries to keep (default 1000).
|
|
14
|
+
*/
|
|
15
|
+
constructor(maxEntries = 1000) {
|
|
16
|
+
this.maxEntries = maxEntries;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Record a new request.
|
|
20
|
+
*/
|
|
21
|
+
record(entry) {
|
|
22
|
+
const fullEntry = {
|
|
23
|
+
id: (0, node_crypto_1.randomUUID)(),
|
|
24
|
+
timestamp: new Date().toISOString(),
|
|
25
|
+
...entry
|
|
26
|
+
};
|
|
27
|
+
this.entries.push(fullEntry);
|
|
28
|
+
// Keep only the most recent entries
|
|
29
|
+
if (this.entries.length > this.maxEntries) {
|
|
30
|
+
this.entries.shift();
|
|
31
|
+
}
|
|
32
|
+
return fullEntry;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Get all history entries, optionally filtered.
|
|
36
|
+
*/
|
|
37
|
+
query(filter) {
|
|
38
|
+
let results = this.entries;
|
|
39
|
+
if (filter?.endpoint) {
|
|
40
|
+
results = results.filter((e) => e.path === filter.endpoint);
|
|
41
|
+
}
|
|
42
|
+
if (filter?.method) {
|
|
43
|
+
results = results.filter((e) => e.method.toUpperCase() === filter.method?.toUpperCase());
|
|
44
|
+
}
|
|
45
|
+
if (filter?.statusCode !== undefined) {
|
|
46
|
+
results = results.filter((e) => e.statusCode === filter.statusCode);
|
|
47
|
+
}
|
|
48
|
+
if (filter?.limit && filter.limit > 0) {
|
|
49
|
+
results = results.slice(-filter.limit);
|
|
50
|
+
}
|
|
51
|
+
return results;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Clear all history entries.
|
|
55
|
+
*/
|
|
56
|
+
clear() {
|
|
57
|
+
this.entries = [];
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Get the total number of recorded entries.
|
|
61
|
+
*/
|
|
62
|
+
count() {
|
|
63
|
+
return this.entries.length;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
exports.HistoryRecorder = HistoryRecorder;
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
4
|
const commander_1 = require("commander");
|
|
5
|
+
const node_fs_1 = require("node:fs");
|
|
6
|
+
const customLogger_js_1 = require("./logger/customLogger.js");
|
|
5
7
|
const program = new commander_1.Command();
|
|
6
8
|
program
|
|
7
9
|
.name("mockserve")
|
|
@@ -12,10 +14,129 @@ program
|
|
|
12
14
|
.description("Start the mock server.")
|
|
13
15
|
.option("-p, --port <number>", "Port to run the server on", "3000")
|
|
14
16
|
.option("-s, --spec <path>", "Path to the spec JSON file", "mock.spec.json")
|
|
15
|
-
.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
.option("--watch", "Reload when spec file changes", true)
|
|
18
|
+
.option("--no-watch", "Disable reload when spec file changes")
|
|
19
|
+
.option("--base-url <url>", "Public base URL used in OpenAPI servers[] (e.g. https://example.com)")
|
|
20
|
+
.option("--log-format <format>", "Log format: pretty or json", "pretty")
|
|
21
|
+
.option("--log-level <level>", "Log level: trace, debug, info, warn, error, fatal", "info")
|
|
22
|
+
.action(async (opts) => {
|
|
23
|
+
await startCommand(opts);
|
|
20
24
|
});
|
|
25
|
+
/**
|
|
26
|
+
* Run the mock server CLI command.
|
|
27
|
+
*/
|
|
28
|
+
async function startCommand(opts) {
|
|
29
|
+
const port = Number(opts.port);
|
|
30
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
31
|
+
console.error(`Invalid port: ${opts.port}`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
const specPath = opts.spec;
|
|
35
|
+
// Create logger based on CLI options
|
|
36
|
+
const logFormat = opts.logFormat === "json" ? "json" : "pretty";
|
|
37
|
+
const logLevel = ["trace", "debug", "info", "warn", "error", "fatal"].includes(opts.logLevel)
|
|
38
|
+
? opts.logLevel
|
|
39
|
+
: "info";
|
|
40
|
+
const logger = (0, customLogger_js_1.createLogger)({
|
|
41
|
+
enabled: true,
|
|
42
|
+
format: logFormat,
|
|
43
|
+
level: logLevel
|
|
44
|
+
});
|
|
45
|
+
const { loadSpecFromFile } = await import("./loadSpec.js");
|
|
46
|
+
const { buildServer } = await import("./server.js");
|
|
47
|
+
let app = null;
|
|
48
|
+
let isReloading = false;
|
|
49
|
+
let debounceTimer = null;
|
|
50
|
+
/**
|
|
51
|
+
* Build and start a server using the current spec file.
|
|
52
|
+
*/
|
|
53
|
+
async function startWithSpec() {
|
|
54
|
+
const loadedAt = new Date().toISOString();
|
|
55
|
+
const spec = await loadSpecFromFile(specPath);
|
|
56
|
+
logger.info(`Loaded spec v${spec.version} with ${spec.endpoints.length} endpoint(s).`);
|
|
57
|
+
const nextApp = buildServer(spec, { specPath, loadedAt, baseUrl: opts.baseUrl, logger: true });
|
|
58
|
+
try {
|
|
59
|
+
await nextApp.listen({ port, host: "0.0.0.0" });
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
nextApp.log.error(err);
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
(0, customLogger_js_1.logServerStart)(nextApp.log, port, specPath);
|
|
66
|
+
return nextApp;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Reload the server when the spec changes.
|
|
70
|
+
*/
|
|
71
|
+
async function reload() {
|
|
72
|
+
if (isReloading)
|
|
73
|
+
return;
|
|
74
|
+
isReloading = true;
|
|
75
|
+
try {
|
|
76
|
+
logger.info("Reloading spec...");
|
|
77
|
+
// 1) Stop accepting requests on the old server FIRST
|
|
78
|
+
if (app) {
|
|
79
|
+
logger.debug("Closing current server...");
|
|
80
|
+
await app.close();
|
|
81
|
+
logger.debug("Current server closed.");
|
|
82
|
+
app = null;
|
|
83
|
+
}
|
|
84
|
+
// 2) Start a new server on the same port with the updated spec
|
|
85
|
+
app = await startWithSpec();
|
|
86
|
+
(0, customLogger_js_1.logServerReload)(logger, true);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
90
|
+
(0, customLogger_js_1.logServerReload)(logger, false, errorMsg);
|
|
91
|
+
// Optional: try to start again to avoid being down
|
|
92
|
+
try {
|
|
93
|
+
if (!app) {
|
|
94
|
+
logger.info("Attempting to start server again after reload failure...");
|
|
95
|
+
app = await startWithSpec();
|
|
96
|
+
logger.info("Recovery start succeeded.");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (err2) {
|
|
100
|
+
logger.error("Recovery start failed. Server is down until next successful reload.");
|
|
101
|
+
logger.error(err2);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
isReloading = false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Initial start
|
|
109
|
+
try {
|
|
110
|
+
app = await startWithSpec();
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
logger.error(err);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
// Watch spec for changes
|
|
117
|
+
/**
|
|
118
|
+
* Handle file changes with a debounced reload.
|
|
119
|
+
*/
|
|
120
|
+
function onSpecChange() {
|
|
121
|
+
debounceTimer = scheduleReload(reload, debounceTimer);
|
|
122
|
+
}
|
|
123
|
+
if (opts.watch) {
|
|
124
|
+
logger.info(`Watching spec file for changes: ${specPath}`);
|
|
125
|
+
// fs.watch emits multiple events; debounce to avoid rapid reload loops
|
|
126
|
+
(0, node_fs_1.watch)(specPath, onSpecChange);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
logger.info("Watch disabled (--no-watch).");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Schedule a debounced reload when the spec changes.
|
|
134
|
+
*/
|
|
135
|
+
function scheduleReload(reload, debounceTimer) {
|
|
136
|
+
if (debounceTimer)
|
|
137
|
+
clearTimeout(debounceTimer);
|
|
138
|
+
return setTimeout(() => {
|
|
139
|
+
void reload();
|
|
140
|
+
}, 200);
|
|
141
|
+
}
|
|
21
142
|
program.parse(process.argv);
|
package/dist/loadSpec.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.loadSpecFromFile = loadSpecFromFile;
|
|
4
|
+
const promises_1 = require("node:fs/promises");
|
|
5
|
+
const spec_js_1 = require("./spec.js");
|
|
6
|
+
/**
|
|
7
|
+
* Load and validate a mock spec from disk.
|
|
8
|
+
*/
|
|
9
|
+
async function loadSpecFromFile(specPath) {
|
|
10
|
+
let raw;
|
|
11
|
+
try {
|
|
12
|
+
raw = await (0, promises_1.readFile)(specPath, 'utf-8');
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
throw new Error(`Failed to read spec file ${specPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
16
|
+
}
|
|
17
|
+
let json;
|
|
18
|
+
try {
|
|
19
|
+
json = JSON.parse(raw);
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
throw new Error(`Failed to parse spec file ${specPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
23
|
+
}
|
|
24
|
+
const parsed = spec_js_1.MockSpecSchema.safeParse(json);
|
|
25
|
+
if (!parsed.success) {
|
|
26
|
+
const issues = parsed.error.issues
|
|
27
|
+
.map((i) => `- ${i.path.join(".") || "(root)"}: ${i.message}`)
|
|
28
|
+
.join("\n");
|
|
29
|
+
throw new Error(`Invalid spec file ${specPath}: ${issues}`);
|
|
30
|
+
}
|
|
31
|
+
return parsed.data;
|
|
32
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.createLogger = createLogger;
|
|
7
|
+
exports.logRequest = logRequest;
|
|
8
|
+
exports.logServerStart = logServerStart;
|
|
9
|
+
exports.logServerReload = logServerReload;
|
|
10
|
+
exports.logEndpointRegistered = logEndpointRegistered;
|
|
11
|
+
const pino_1 = __importDefault(require("pino"));
|
|
12
|
+
const formatters_js_1 = require("./formatters.js");
|
|
13
|
+
/**
|
|
14
|
+
* Create a custom logger for the mock server.
|
|
15
|
+
*/
|
|
16
|
+
function createLogger(options) {
|
|
17
|
+
if (!options.enabled) {
|
|
18
|
+
return (0, pino_1.default)({ level: "silent" });
|
|
19
|
+
}
|
|
20
|
+
if (options.format === "json") {
|
|
21
|
+
return (0, pino_1.default)({
|
|
22
|
+
level: options.level,
|
|
23
|
+
timestamp: pino_1.default.stdTimeFunctions.isoTime
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
// Pretty format with custom output (simplified to avoid serialization issues in tests)
|
|
27
|
+
return (0, pino_1.default)({
|
|
28
|
+
level: options.level,
|
|
29
|
+
transport: {
|
|
30
|
+
target: "pino-pretty",
|
|
31
|
+
options: {
|
|
32
|
+
colorize: true,
|
|
33
|
+
translateTime: "HH:MM:ss",
|
|
34
|
+
ignore: "pid,hostname",
|
|
35
|
+
messageFormat: "{msg}"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Format and log a request/response pair.
|
|
42
|
+
*/
|
|
43
|
+
function logRequest(logger, method, url, statusCode, responseTime) {
|
|
44
|
+
const formattedMethod = (0, formatters_js_1.formatMethod)(method);
|
|
45
|
+
const formattedStatus = (0, formatters_js_1.formatStatusCode)(statusCode);
|
|
46
|
+
const formattedTime = (0, formatters_js_1.formatResponseTime)(responseTime);
|
|
47
|
+
logger.info(`${formattedMethod} ${url} ${formattedStatus} ${formattedTime}`);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Log server startup.
|
|
51
|
+
*/
|
|
52
|
+
function logServerStart(logger, port, specPath) {
|
|
53
|
+
logger.info(`🚀 Mock server running on http://localhost:${port}`);
|
|
54
|
+
logger.info(`📄 Spec: ${specPath}`);
|
|
55
|
+
logger.info(`📖 Docs: http://localhost:${port}/docs`);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Log server reload.
|
|
59
|
+
*/
|
|
60
|
+
function logServerReload(logger, success, error) {
|
|
61
|
+
if (success) {
|
|
62
|
+
logger.info("✅ Spec reloaded successfully");
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
logger.error(`❌ Reload failed: ${error}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Log endpoint registration.
|
|
70
|
+
*/
|
|
71
|
+
function logEndpointRegistered(logger, method, path, status) {
|
|
72
|
+
const formattedMethod = (0, formatters_js_1.formatMethod)(method);
|
|
73
|
+
const statusInfo = status ? ` → ${status}` : "";
|
|
74
|
+
logger.debug(`Registered ${formattedMethod} ${path}${statusInfo}`);
|
|
75
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.formatStatusCode = formatStatusCode;
|
|
7
|
+
exports.formatMethod = formatMethod;
|
|
8
|
+
exports.formatResponseTime = formatResponseTime;
|
|
9
|
+
exports.formatTimestamp = formatTimestamp;
|
|
10
|
+
exports.formatLogLevel = formatLogLevel;
|
|
11
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
12
|
+
/**
|
|
13
|
+
* Format HTTP status code with color based on status range.
|
|
14
|
+
*/
|
|
15
|
+
function formatStatusCode(statusCode) {
|
|
16
|
+
if (statusCode >= 500) {
|
|
17
|
+
return chalk_1.default.red(statusCode.toString());
|
|
18
|
+
}
|
|
19
|
+
if (statusCode >= 400) {
|
|
20
|
+
return chalk_1.default.yellow(statusCode.toString());
|
|
21
|
+
}
|
|
22
|
+
if (statusCode >= 300) {
|
|
23
|
+
return chalk_1.default.cyan(statusCode.toString());
|
|
24
|
+
}
|
|
25
|
+
if (statusCode >= 200) {
|
|
26
|
+
return chalk_1.default.green(statusCode.toString());
|
|
27
|
+
}
|
|
28
|
+
return chalk_1.default.white(statusCode.toString());
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Format HTTP method with color.
|
|
32
|
+
*/
|
|
33
|
+
function formatMethod(method) {
|
|
34
|
+
const colors = {
|
|
35
|
+
GET: chalk_1.default.green,
|
|
36
|
+
POST: chalk_1.default.blue,
|
|
37
|
+
PUT: chalk_1.default.yellow,
|
|
38
|
+
PATCH: chalk_1.default.magenta,
|
|
39
|
+
DELETE: chalk_1.default.red,
|
|
40
|
+
HEAD: chalk_1.default.gray,
|
|
41
|
+
OPTIONS: chalk_1.default.cyan
|
|
42
|
+
};
|
|
43
|
+
const formatter = colors[method.toUpperCase()] || chalk_1.default.white;
|
|
44
|
+
return formatter(method.toUpperCase().padEnd(7));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Format response time with color based on duration.
|
|
48
|
+
*/
|
|
49
|
+
function formatResponseTime(ms) {
|
|
50
|
+
const formatted = `${ms.toFixed(2)}ms`;
|
|
51
|
+
if (ms > 1000)
|
|
52
|
+
return chalk_1.default.red(formatted);
|
|
53
|
+
if (ms > 500)
|
|
54
|
+
return chalk_1.default.yellow(formatted);
|
|
55
|
+
if (ms > 100)
|
|
56
|
+
return chalk_1.default.cyan(formatted);
|
|
57
|
+
return chalk_1.default.green(formatted);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Format timestamp in readable format.
|
|
61
|
+
*/
|
|
62
|
+
function formatTimestamp(date) {
|
|
63
|
+
const hours = date.getHours().toString().padStart(2, "0");
|
|
64
|
+
const minutes = date.getMinutes().toString().padStart(2, "0");
|
|
65
|
+
const seconds = date.getSeconds().toString().padStart(2, "0");
|
|
66
|
+
return chalk_1.default.gray(`[${hours}:${minutes}:${seconds}]`);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Format a log level with appropriate color.
|
|
70
|
+
*/
|
|
71
|
+
function formatLogLevel(level) {
|
|
72
|
+
const colors = {
|
|
73
|
+
trace: chalk_1.default.gray,
|
|
74
|
+
debug: chalk_1.default.cyan,
|
|
75
|
+
info: chalk_1.default.blue,
|
|
76
|
+
warn: chalk_1.default.yellow,
|
|
77
|
+
error: chalk_1.default.red,
|
|
78
|
+
fatal: chalk_1.default.bgRed.white
|
|
79
|
+
};
|
|
80
|
+
const formatter = colors[level.toLowerCase()] || chalk_1.default.white;
|
|
81
|
+
return formatter(level.toUpperCase().padEnd(5));
|
|
82
|
+
}
|
package/dist/openapi.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.generateOpenApi = generateOpenApi;
|
|
4
|
+
/**
|
|
5
|
+
* Convert Fastify-style route params to OpenAPI style.
|
|
6
|
+
*/
|
|
7
|
+
function toOpenApiPath(fastifyPath) {
|
|
8
|
+
// Fastify style: /users/:id -> OpenAPI style: /users/{id}
|
|
9
|
+
return fastifyPath.replace(/:([A-Za-z0-9_]+)/g, "{$1}");
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Extract path parameter names from a Fastify-style route.
|
|
13
|
+
*/
|
|
14
|
+
function extractPathParams(fastifyPath) {
|
|
15
|
+
const matches = [...fastifyPath.matchAll(/:([A-Za-z0-9_]+)/g)];
|
|
16
|
+
return matches.map((m) => m[1]);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Deduplicate values while preserving order.
|
|
20
|
+
*/
|
|
21
|
+
function uniq(items) {
|
|
22
|
+
return [...new Set(items)];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Convert a list of values into a list of string enums.
|
|
26
|
+
*/
|
|
27
|
+
function asStringEnum(values) {
|
|
28
|
+
return uniq(values
|
|
29
|
+
.filter((v) => v !== undefined && v !== null)
|
|
30
|
+
.map((v) => String(v)));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Generate a minimal OpenAPI document for the mock spec.
|
|
34
|
+
*/
|
|
35
|
+
function generateOpenApi(spec, serverUrl) {
|
|
36
|
+
const paths = {};
|
|
37
|
+
for (const ep of spec.endpoints) {
|
|
38
|
+
const oasPath = toOpenApiPath(ep.path);
|
|
39
|
+
const method = ep.method.toLowerCase();
|
|
40
|
+
const pathParams = extractPathParams(ep.path);
|
|
41
|
+
// Collect query match keys/values across endpoint + variants
|
|
42
|
+
const queryMatchValues = {};
|
|
43
|
+
const bodyMatchKeys = new Set();
|
|
44
|
+
/**
|
|
45
|
+
* Collect query match values into a set for documentation.
|
|
46
|
+
*/
|
|
47
|
+
const collectQuery = (obj) => {
|
|
48
|
+
if (!obj)
|
|
49
|
+
return;
|
|
50
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
51
|
+
if (!queryMatchValues[k])
|
|
52
|
+
queryMatchValues[k] = [];
|
|
53
|
+
queryMatchValues[k].push(String(v));
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Collect body match keys for request body documentation.
|
|
58
|
+
*/
|
|
59
|
+
const collectBody = (obj) => {
|
|
60
|
+
if (!obj)
|
|
61
|
+
return;
|
|
62
|
+
for (const k of Object.keys(obj))
|
|
63
|
+
bodyMatchKeys.add(k);
|
|
64
|
+
};
|
|
65
|
+
collectQuery(ep.match?.query);
|
|
66
|
+
collectBody(ep.match?.body);
|
|
67
|
+
if (ep.variants?.length) {
|
|
68
|
+
for (const v of ep.variants) {
|
|
69
|
+
collectQuery(v.match?.query);
|
|
70
|
+
collectBody(v.match?.body);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Parameters: path params + known query keys
|
|
74
|
+
const parameters = [];
|
|
75
|
+
for (const p of pathParams) {
|
|
76
|
+
parameters.push({
|
|
77
|
+
name: p,
|
|
78
|
+
in: "path",
|
|
79
|
+
required: true,
|
|
80
|
+
schema: { type: "string" }
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
for (const [k, vals] of Object.entries(queryMatchValues)) {
|
|
84
|
+
const enumVals = asStringEnum(vals);
|
|
85
|
+
parameters.push({
|
|
86
|
+
name: k,
|
|
87
|
+
in: "query",
|
|
88
|
+
required: false,
|
|
89
|
+
schema: enumVals.length > 0 ? { type: "string", enum: enumVals } : { type: "string" },
|
|
90
|
+
description: "Query param used by mock matching (if configured)."
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
// Request body: for non-GET/DELETE, document as generic object with known keys (from match rules)
|
|
94
|
+
const hasRequestBody = ep.method !== "GET" && ep.method !== "DELETE";
|
|
95
|
+
const requestBody = hasRequestBody && bodyMatchKeys.size > 0
|
|
96
|
+
? {
|
|
97
|
+
required: false,
|
|
98
|
+
content: {
|
|
99
|
+
"application/json": {
|
|
100
|
+
schema: {
|
|
101
|
+
type: "object",
|
|
102
|
+
properties: Object.fromEntries([...bodyMatchKeys].map((k) => [k, { type: "string" }]))
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
: undefined;
|
|
108
|
+
// Responses: base + variants (grouped per status)
|
|
109
|
+
const responses = {};
|
|
110
|
+
/**
|
|
111
|
+
* Add a response example to the OpenAPI response map.
|
|
112
|
+
*/
|
|
113
|
+
const addResponseExample = (status, name, example) => {
|
|
114
|
+
const key = String(status);
|
|
115
|
+
if (!responses[key]) {
|
|
116
|
+
responses[key] = {
|
|
117
|
+
description: "Mock response",
|
|
118
|
+
content: { "application/json": { examples: {} } }
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const examples = responses[key].content["application/json"].examples;
|
|
122
|
+
examples[name] = { value: example };
|
|
123
|
+
};
|
|
124
|
+
// Base response
|
|
125
|
+
addResponseExample(ep.status ?? 200, "default", ep.response);
|
|
126
|
+
// Variant responses
|
|
127
|
+
if (ep.variants?.length) {
|
|
128
|
+
for (const v of ep.variants) {
|
|
129
|
+
addResponseExample(v.status ?? ep.status ?? 200, v.name ?? "variant", v.response);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const operation = {
|
|
133
|
+
summary: `Mock ${ep.method} ${ep.path}`,
|
|
134
|
+
parameters: parameters.length ? parameters : undefined,
|
|
135
|
+
requestBody,
|
|
136
|
+
responses
|
|
137
|
+
};
|
|
138
|
+
if (!paths[oasPath])
|
|
139
|
+
paths[oasPath] = {};
|
|
140
|
+
paths[oasPath][method] = operation;
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
openapi: "3.0.3",
|
|
144
|
+
info: {
|
|
145
|
+
title: "mockserve",
|
|
146
|
+
version: "0.1.0",
|
|
147
|
+
description: "OpenAPI document generated from mockserve JSON spec."
|
|
148
|
+
},
|
|
149
|
+
servers: [{ url: serverUrl }],
|
|
150
|
+
paths
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerEndpoints = registerEndpoints;
|
|
4
|
+
const requestMatch_js_1 = require("./requestMatch.js");
|
|
5
|
+
const responseRenderer_js_1 = require("./responseRenderer.js");
|
|
6
|
+
const behavior_js_1 = require("./behavior.js");
|
|
7
|
+
const customLogger_js_1 = require("./logger/customLogger.js");
|
|
8
|
+
/**
|
|
9
|
+
* Select a response source from the first matching variant.
|
|
10
|
+
*/
|
|
11
|
+
function selectVariant(req, endpoint) {
|
|
12
|
+
if (!endpoint.variants || endpoint.variants.length === 0)
|
|
13
|
+
return null;
|
|
14
|
+
for (const variant of endpoint.variants) {
|
|
15
|
+
if ((0, requestMatch_js_1.matchRequest)(req, variant.match)) {
|
|
16
|
+
return {
|
|
17
|
+
status: variant.status,
|
|
18
|
+
response: variant.response,
|
|
19
|
+
headers: variant.headers,
|
|
20
|
+
delay: variant.delay,
|
|
21
|
+
delayMs: variant.delayMs,
|
|
22
|
+
errorRate: variant.errorRate,
|
|
23
|
+
errorStatus: variant.errorStatus,
|
|
24
|
+
errorResponse: variant.errorResponse
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Build a response source from the endpoint itself.
|
|
32
|
+
*/
|
|
33
|
+
function selectEndpointSource(endpoint) {
|
|
34
|
+
return {
|
|
35
|
+
status: endpoint.status,
|
|
36
|
+
response: endpoint.response,
|
|
37
|
+
headers: endpoint.headers,
|
|
38
|
+
delay: endpoint.delay,
|
|
39
|
+
delayMs: endpoint.delayMs,
|
|
40
|
+
errorRate: endpoint.errorRate,
|
|
41
|
+
errorStatus: endpoint.errorStatus,
|
|
42
|
+
errorResponse: endpoint.errorResponse
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Register all endpoints defined in a mock spec.
|
|
47
|
+
*/
|
|
48
|
+
function registerEndpoints(app, spec) {
|
|
49
|
+
for (const endpoint of spec.endpoints) {
|
|
50
|
+
app.route({
|
|
51
|
+
method: endpoint.method,
|
|
52
|
+
url: endpoint.path,
|
|
53
|
+
handler: buildEndpointHandler(spec, endpoint)
|
|
54
|
+
});
|
|
55
|
+
(0, customLogger_js_1.logEndpointRegistered)(app.log, endpoint.method, endpoint.path, endpoint.status);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Build a Fastify handler for a single endpoint definition.
|
|
60
|
+
*/
|
|
61
|
+
function buildEndpointHandler(spec, endpoint) {
|
|
62
|
+
return async (req, reply) => {
|
|
63
|
+
const variant = selectVariant(req, endpoint);
|
|
64
|
+
if (!variant && !(0, requestMatch_js_1.matchRequest)(req, endpoint.match)) {
|
|
65
|
+
reply.code(404);
|
|
66
|
+
return { error: "No matching mock for request" };
|
|
67
|
+
}
|
|
68
|
+
const source = variant ?? selectEndpointSource(endpoint);
|
|
69
|
+
const behavior = (0, behavior_js_1.resolveBehavior)(spec.settings, endpoint, source);
|
|
70
|
+
// Resolve delay (supports both delay and delayMs, with delay taking precedence)
|
|
71
|
+
const delayValue = source.delay ? (0, behavior_js_1.resolveDelay)(source.delay) : behavior.delayMs;
|
|
72
|
+
if (delayValue > 0) {
|
|
73
|
+
await (0, behavior_js_1.sleep)(delayValue);
|
|
74
|
+
}
|
|
75
|
+
const params = (0, requestMatch_js_1.toRecord)(req.params);
|
|
76
|
+
const query = (0, requestMatch_js_1.toRecord)(req.query);
|
|
77
|
+
const body = req.body;
|
|
78
|
+
const renderContext = (0, responseRenderer_js_1.createRenderContext)({ params, query, body }, spec.settings.fakerSeed);
|
|
79
|
+
if ((0, behavior_js_1.shouldFail)(behavior.errorRate)) {
|
|
80
|
+
reply.code(behavior.errorStatus);
|
|
81
|
+
return (0, responseRenderer_js_1.renderTemplateValue)(behavior.errorResponse, renderContext);
|
|
82
|
+
}
|
|
83
|
+
// Apply custom headers (with template support)
|
|
84
|
+
const headers = source.headers ?? endpoint.headers;
|
|
85
|
+
if (headers) {
|
|
86
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
87
|
+
const renderedValue = typeof value === "string"
|
|
88
|
+
? String((0, responseRenderer_js_1.renderTemplateValue)(value, renderContext))
|
|
89
|
+
: String(value);
|
|
90
|
+
reply.header(key, renderedValue);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const rendered = (0, responseRenderer_js_1.renderTemplateValue)(source.response, renderContext);
|
|
94
|
+
reply.code(source.status ?? endpoint.status ?? 200);
|
|
95
|
+
return rendered;
|
|
96
|
+
};
|
|
97
|
+
}
|