api-json-server 1.1.0 → 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 +476 -126
- package/dist/behavior.js +12 -0
- package/dist/history/historyRecorder.js +66 -0
- package/dist/history/types.js +2 -0
- package/dist/index.js +29 -18
- package/dist/logger/customLogger.js +75 -0
- package/dist/logger/formatters.js +82 -0
- package/dist/logger/types.js +2 -0
- package/dist/registerEndpoints.js +20 -3
- package/dist/requestMatch.js +40 -1
- package/dist/server.js +62 -2
- package/dist/spec.js +38 -2
- package/package.json +6 -1
- package/src/behavior.ts +15 -1
- package/src/history/historyRecorder.ts +77 -0
- package/src/history/types.ts +25 -0
- package/src/index.ts +34 -21
- package/src/logger/customLogger.ts +85 -0
- package/src/logger/formatters.ts +74 -0
- package/src/logger/types.ts +30 -0
- package/src/registerEndpoints.ts +24 -6
- package/src/requestMatch.ts +43 -1
- package/src/server.ts +77 -4
- package/src/spec.ts +40 -2
- package/tests/cors.test.ts +128 -0
- package/tests/headers.test.ts +124 -0
- package/tests/helpers.ts +2 -2
- package/tests/history.test.ts +188 -0
- package/tests/matching.test.ts +109 -0
package/dist/behavior.js
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveDelay = resolveDelay;
|
|
3
4
|
exports.sleep = sleep;
|
|
4
5
|
exports.shouldFail = shouldFail;
|
|
5
6
|
exports.resolveBehavior = resolveBehavior;
|
|
7
|
+
const faker_1 = require("@faker-js/faker");
|
|
8
|
+
/**
|
|
9
|
+
* Resolve a delay value from either a number or a range configuration.
|
|
10
|
+
*/
|
|
11
|
+
function resolveDelay(delay) {
|
|
12
|
+
if (!delay)
|
|
13
|
+
return 0;
|
|
14
|
+
if (typeof delay === "number")
|
|
15
|
+
return delay;
|
|
16
|
+
return faker_1.faker.number.int({ min: delay.min, max: delay.max });
|
|
17
|
+
}
|
|
6
18
|
/**
|
|
7
19
|
* Pause for the given number of milliseconds.
|
|
8
20
|
*/
|
|
@@ -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
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
4
|
const commander_1 = require("commander");
|
|
5
5
|
const node_fs_1 = require("node:fs");
|
|
6
|
+
const customLogger_js_1 = require("./logger/customLogger.js");
|
|
6
7
|
const program = new commander_1.Command();
|
|
7
8
|
program
|
|
8
9
|
.name("mockserve")
|
|
@@ -16,6 +17,8 @@ program
|
|
|
16
17
|
.option("--watch", "Reload when spec file changes", true)
|
|
17
18
|
.option("--no-watch", "Disable reload when spec file changes")
|
|
18
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")
|
|
19
22
|
.action(async (opts) => {
|
|
20
23
|
await startCommand(opts);
|
|
21
24
|
});
|
|
@@ -29,6 +32,16 @@ async function startCommand(opts) {
|
|
|
29
32
|
process.exit(1);
|
|
30
33
|
}
|
|
31
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
|
+
});
|
|
32
45
|
const { loadSpecFromFile } = await import("./loadSpec.js");
|
|
33
46
|
const { buildServer } = await import("./server.js");
|
|
34
47
|
let app = null;
|
|
@@ -40,8 +53,8 @@ async function startCommand(opts) {
|
|
|
40
53
|
async function startWithSpec() {
|
|
41
54
|
const loadedAt = new Date().toISOString();
|
|
42
55
|
const spec = await loadSpecFromFile(specPath);
|
|
43
|
-
|
|
44
|
-
const nextApp = buildServer(spec, { specPath, loadedAt, baseUrl: opts.baseUrl });
|
|
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 });
|
|
45
58
|
try {
|
|
46
59
|
await nextApp.listen({ port, host: "0.0.0.0" });
|
|
47
60
|
}
|
|
@@ -49,8 +62,7 @@ async function startCommand(opts) {
|
|
|
49
62
|
nextApp.log.error(err);
|
|
50
63
|
throw err;
|
|
51
64
|
}
|
|
52
|
-
nextApp.log
|
|
53
|
-
nextApp.log.info(`Spec: ${specPath} (loadedAt=${loadedAt})`);
|
|
65
|
+
(0, customLogger_js_1.logServerStart)(nextApp.log, port, specPath);
|
|
54
66
|
return nextApp;
|
|
55
67
|
}
|
|
56
68
|
/**
|
|
@@ -61,33 +73,32 @@ async function startCommand(opts) {
|
|
|
61
73
|
return;
|
|
62
74
|
isReloading = true;
|
|
63
75
|
try {
|
|
64
|
-
|
|
76
|
+
logger.info("Reloading spec...");
|
|
65
77
|
// 1) Stop accepting requests on the old server FIRST
|
|
66
78
|
if (app) {
|
|
67
|
-
|
|
79
|
+
logger.debug("Closing current server...");
|
|
68
80
|
await app.close();
|
|
69
|
-
|
|
81
|
+
logger.debug("Current server closed.");
|
|
70
82
|
app = null;
|
|
71
83
|
}
|
|
72
84
|
// 2) Start a new server on the same port with the updated spec
|
|
73
85
|
app = await startWithSpec();
|
|
74
|
-
|
|
86
|
+
(0, customLogger_js_1.logServerReload)(logger, true);
|
|
75
87
|
}
|
|
76
88
|
catch (err) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
console.error(String(err));
|
|
89
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
90
|
+
(0, customLogger_js_1.logServerReload)(logger, false, errorMsg);
|
|
80
91
|
// Optional: try to start again to avoid being down
|
|
81
92
|
try {
|
|
82
93
|
if (!app) {
|
|
83
|
-
|
|
94
|
+
logger.info("Attempting to start server again after reload failure...");
|
|
84
95
|
app = await startWithSpec();
|
|
85
|
-
|
|
96
|
+
logger.info("Recovery start succeeded.");
|
|
86
97
|
}
|
|
87
98
|
}
|
|
88
99
|
catch (err2) {
|
|
89
|
-
|
|
90
|
-
|
|
100
|
+
logger.error("Recovery start failed. Server is down until next successful reload.");
|
|
101
|
+
logger.error(err2);
|
|
91
102
|
}
|
|
92
103
|
}
|
|
93
104
|
finally {
|
|
@@ -99,7 +110,7 @@ async function startCommand(opts) {
|
|
|
99
110
|
app = await startWithSpec();
|
|
100
111
|
}
|
|
101
112
|
catch (err) {
|
|
102
|
-
|
|
113
|
+
logger.error(err);
|
|
103
114
|
process.exit(1);
|
|
104
115
|
}
|
|
105
116
|
// Watch spec for changes
|
|
@@ -110,12 +121,12 @@ async function startCommand(opts) {
|
|
|
110
121
|
debounceTimer = scheduleReload(reload, debounceTimer);
|
|
111
122
|
}
|
|
112
123
|
if (opts.watch) {
|
|
113
|
-
|
|
124
|
+
logger.info(`Watching spec file for changes: ${specPath}`);
|
|
114
125
|
// fs.watch emits multiple events; debounce to avoid rapid reload loops
|
|
115
126
|
(0, node_fs_1.watch)(specPath, onSpecChange);
|
|
116
127
|
}
|
|
117
128
|
else {
|
|
118
|
-
|
|
129
|
+
logger.info("Watch disabled (--no-watch).");
|
|
119
130
|
}
|
|
120
131
|
}
|
|
121
132
|
/**
|
|
@@ -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
|
+
}
|
|
@@ -4,6 +4,7 @@ exports.registerEndpoints = registerEndpoints;
|
|
|
4
4
|
const requestMatch_js_1 = require("./requestMatch.js");
|
|
5
5
|
const responseRenderer_js_1 = require("./responseRenderer.js");
|
|
6
6
|
const behavior_js_1 = require("./behavior.js");
|
|
7
|
+
const customLogger_js_1 = require("./logger/customLogger.js");
|
|
7
8
|
/**
|
|
8
9
|
* Select a response source from the first matching variant.
|
|
9
10
|
*/
|
|
@@ -15,6 +16,8 @@ function selectVariant(req, endpoint) {
|
|
|
15
16
|
return {
|
|
16
17
|
status: variant.status,
|
|
17
18
|
response: variant.response,
|
|
19
|
+
headers: variant.headers,
|
|
20
|
+
delay: variant.delay,
|
|
18
21
|
delayMs: variant.delayMs,
|
|
19
22
|
errorRate: variant.errorRate,
|
|
20
23
|
errorStatus: variant.errorStatus,
|
|
@@ -31,6 +34,8 @@ function selectEndpointSource(endpoint) {
|
|
|
31
34
|
return {
|
|
32
35
|
status: endpoint.status,
|
|
33
36
|
response: endpoint.response,
|
|
37
|
+
headers: endpoint.headers,
|
|
38
|
+
delay: endpoint.delay,
|
|
34
39
|
delayMs: endpoint.delayMs,
|
|
35
40
|
errorRate: endpoint.errorRate,
|
|
36
41
|
errorStatus: endpoint.errorStatus,
|
|
@@ -47,7 +52,7 @@ function registerEndpoints(app, spec) {
|
|
|
47
52
|
url: endpoint.path,
|
|
48
53
|
handler: buildEndpointHandler(spec, endpoint)
|
|
49
54
|
});
|
|
50
|
-
app.log
|
|
55
|
+
(0, customLogger_js_1.logEndpointRegistered)(app.log, endpoint.method, endpoint.path, endpoint.status);
|
|
51
56
|
}
|
|
52
57
|
}
|
|
53
58
|
/**
|
|
@@ -62,8 +67,10 @@ function buildEndpointHandler(spec, endpoint) {
|
|
|
62
67
|
}
|
|
63
68
|
const source = variant ?? selectEndpointSource(endpoint);
|
|
64
69
|
const behavior = (0, behavior_js_1.resolveBehavior)(spec.settings, endpoint, source);
|
|
65
|
-
|
|
66
|
-
|
|
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);
|
|
67
74
|
}
|
|
68
75
|
const params = (0, requestMatch_js_1.toRecord)(req.params);
|
|
69
76
|
const query = (0, requestMatch_js_1.toRecord)(req.query);
|
|
@@ -73,6 +80,16 @@ function buildEndpointHandler(spec, endpoint) {
|
|
|
73
80
|
reply.code(behavior.errorStatus);
|
|
74
81
|
return (0, responseRenderer_js_1.renderTemplateValue)(behavior.errorResponse, renderContext);
|
|
75
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
|
+
}
|
|
76
93
|
const rendered = (0, responseRenderer_js_1.renderTemplateValue)(source.response, renderContext);
|
|
77
94
|
reply.code(source.status ?? endpoint.status ?? 200);
|
|
78
95
|
return rendered;
|
package/dist/requestMatch.js
CHANGED
|
@@ -47,7 +47,42 @@ function bodyMatches(req, expected) {
|
|
|
47
47
|
return true;
|
|
48
48
|
}
|
|
49
49
|
/**
|
|
50
|
-
* Check if
|
|
50
|
+
* Check if the request headers match the expected values (case-insensitive).
|
|
51
|
+
*/
|
|
52
|
+
function headersMatch(req, expected) {
|
|
53
|
+
if (!expected)
|
|
54
|
+
return true;
|
|
55
|
+
// Normalize header keys to lowercase for case-insensitive matching
|
|
56
|
+
const headers = new Map();
|
|
57
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
58
|
+
if (typeof value === "string") {
|
|
59
|
+
headers.set(key.toLowerCase(), value);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (const [key, exp] of Object.entries(expected)) {
|
|
63
|
+
const actual = headers.get(key.toLowerCase());
|
|
64
|
+
if (actual !== exp)
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Check if the request cookies match the expected values.
|
|
71
|
+
*/
|
|
72
|
+
function cookiesMatch(req, expected) {
|
|
73
|
+
if (!expected)
|
|
74
|
+
return true;
|
|
75
|
+
const cookies = req.cookies;
|
|
76
|
+
if (!cookies)
|
|
77
|
+
return false;
|
|
78
|
+
for (const [key, exp] of Object.entries(expected)) {
|
|
79
|
+
if (cookies[key] !== exp)
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Check if a request matches query/body/headers/cookies rules.
|
|
51
86
|
*/
|
|
52
87
|
function matchRequest(req, match) {
|
|
53
88
|
if (!match)
|
|
@@ -56,5 +91,9 @@ function matchRequest(req, match) {
|
|
|
56
91
|
return false;
|
|
57
92
|
if (!bodyMatches(req, match.body))
|
|
58
93
|
return false;
|
|
94
|
+
if (!headersMatch(req, match.headers))
|
|
95
|
+
return false;
|
|
96
|
+
if (!cookiesMatch(req, match.cookies))
|
|
97
|
+
return false;
|
|
59
98
|
return true;
|
|
60
99
|
}
|
package/dist/server.js
CHANGED
|
@@ -9,7 +9,10 @@ const registerEndpoints_js_1 = require("./registerEndpoints.js");
|
|
|
9
9
|
const swagger_ui_dist_1 = __importDefault(require("swagger-ui-dist"));
|
|
10
10
|
const openapi_js_1 = require("./openapi.js");
|
|
11
11
|
const static_1 = __importDefault(require("@fastify/static"));
|
|
12
|
+
const cookie_1 = __importDefault(require("@fastify/cookie"));
|
|
13
|
+
const cors_1 = __importDefault(require("@fastify/cors"));
|
|
12
14
|
const yaml_1 = __importDefault(require("yaml"));
|
|
15
|
+
const historyRecorder_js_1 = require("./history/historyRecorder.js");
|
|
13
16
|
/**
|
|
14
17
|
* Resolve the path to swagger-ui-dist assets.
|
|
15
18
|
*/
|
|
@@ -40,8 +43,50 @@ function resolveServerUrl(req, baseUrl) {
|
|
|
40
43
|
*/
|
|
41
44
|
function buildServer(spec, meta) {
|
|
42
45
|
const app = (0, fastify_1.default)({
|
|
43
|
-
logger: true,
|
|
44
|
-
trustProxy: true
|
|
46
|
+
logger: meta?.logger ?? true,
|
|
47
|
+
trustProxy: true,
|
|
48
|
+
disableRequestLogging: false
|
|
49
|
+
});
|
|
50
|
+
// Register CORS if configured
|
|
51
|
+
if (spec.settings.cors) {
|
|
52
|
+
app.register(cors_1.default, {
|
|
53
|
+
origin: spec.settings.cors.origin ?? true,
|
|
54
|
+
credentials: spec.settings.cors.credentials ?? false,
|
|
55
|
+
methods: spec.settings.cors.methods,
|
|
56
|
+
allowedHeaders: spec.settings.cors.allowedHeaders,
|
|
57
|
+
exposedHeaders: spec.settings.cors.exposedHeaders,
|
|
58
|
+
maxAge: spec.settings.cors.maxAge
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
// Register cookie parser plugin
|
|
62
|
+
app.register(cookie_1.default);
|
|
63
|
+
// Create history recorder
|
|
64
|
+
const history = new historyRecorder_js_1.HistoryRecorder(1000);
|
|
65
|
+
// Record all requests in onRequest hook (after body parsing)
|
|
66
|
+
app.addHook("preHandler", async (req, reply) => {
|
|
67
|
+
const startTime = Date.now();
|
|
68
|
+
// Store start time for response hook
|
|
69
|
+
req.startTime = startTime;
|
|
70
|
+
history.record({
|
|
71
|
+
method: req.method,
|
|
72
|
+
url: req.url,
|
|
73
|
+
path: req.routeOptions?.url ?? req.url.split("?")[0],
|
|
74
|
+
query: req.query,
|
|
75
|
+
headers: req.headers,
|
|
76
|
+
body: req.body
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
// Update history with response details in onResponse hook
|
|
80
|
+
app.addHook("onResponse", async (req, reply) => {
|
|
81
|
+
const startTime = req.startTime ?? Date.now();
|
|
82
|
+
const responseTime = Date.now() - startTime;
|
|
83
|
+
// Find and update the last entry (just added in onRequest)
|
|
84
|
+
const entries = history.query({ limit: 1 });
|
|
85
|
+
if (entries.length > 0) {
|
|
86
|
+
const lastEntry = entries[entries.length - 1];
|
|
87
|
+
lastEntry.statusCode = reply.statusCode;
|
|
88
|
+
lastEntry.responseTime = responseTime;
|
|
89
|
+
}
|
|
45
90
|
});
|
|
46
91
|
/**
|
|
47
92
|
* Handler for the /__spec route with bound metadata.
|
|
@@ -79,6 +124,21 @@ function buildServer(spec, meta) {
|
|
|
79
124
|
decorateReply: false
|
|
80
125
|
});
|
|
81
126
|
app.get("/docs", docsRouteHandler);
|
|
127
|
+
// History endpoints
|
|
128
|
+
app.get("/__history", async (req) => {
|
|
129
|
+
const query = req.query;
|
|
130
|
+
const filter = {
|
|
131
|
+
endpoint: query.endpoint,
|
|
132
|
+
method: query.method,
|
|
133
|
+
statusCode: query.statusCode ? Number(query.statusCode) : undefined,
|
|
134
|
+
limit: query.limit ? Number(query.limit) : undefined
|
|
135
|
+
};
|
|
136
|
+
return { entries: history.query(filter), total: history.count() };
|
|
137
|
+
});
|
|
138
|
+
app.delete("/__history", async () => {
|
|
139
|
+
history.clear();
|
|
140
|
+
return { ok: true, message: "History cleared" };
|
|
141
|
+
});
|
|
82
142
|
(0, registerEndpoints_js_1.registerEndpoints)(app, spec);
|
|
83
143
|
return app;
|
|
84
144
|
}
|
package/dist/spec.js
CHANGED
|
@@ -68,15 +68,28 @@ const TemplateValueSchema = z.lazy(() => z.union([
|
|
|
68
68
|
exports.MatchSchema = z.object({
|
|
69
69
|
query: z.record(z.string(), PrimitiveSchema).optional(),
|
|
70
70
|
// Exact match for top-level body fields only (keeps v1 simple)
|
|
71
|
-
body: z.record(z.string(), PrimitiveSchema).optional()
|
|
71
|
+
body: z.record(z.string(), PrimitiveSchema).optional(),
|
|
72
|
+
// Header matching (case-insensitive keys)
|
|
73
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
74
|
+
// Cookie matching
|
|
75
|
+
cookies: z.record(z.string(), z.string()).optional()
|
|
72
76
|
});
|
|
77
|
+
const DelaySchema = z.union([
|
|
78
|
+
z.number().int().min(0),
|
|
79
|
+
z.object({
|
|
80
|
+
min: z.number().int().min(0),
|
|
81
|
+
max: z.number().int().min(0)
|
|
82
|
+
})
|
|
83
|
+
]);
|
|
73
84
|
exports.VariantSchema = z.object({
|
|
74
85
|
name: z.string().min(1).optional(),
|
|
75
86
|
match: exports.MatchSchema.optional(),
|
|
76
87
|
status: z.number().int().min(100).max(599).optional(),
|
|
77
88
|
response: TemplateValueSchema,
|
|
89
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
78
90
|
// Simulation overrides per variant (optional)
|
|
79
91
|
delayMs: z.number().int().min(0).optional(),
|
|
92
|
+
delay: DelaySchema.optional(),
|
|
80
93
|
errorRate: z.number().min(0).max(1).optional(),
|
|
81
94
|
errorStatus: z.number().int().min(100).max(599).optional(),
|
|
82
95
|
errorResponse: TemplateValueSchema.optional()
|
|
@@ -89,8 +102,21 @@ exports.EndpointSchema = z.object({
|
|
|
89
102
|
// Response behavior:
|
|
90
103
|
status: z.number().int().min(200).max(599).default(200),
|
|
91
104
|
response: TemplateValueSchema,
|
|
105
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
106
|
+
// Per-endpoint CORS override
|
|
107
|
+
cors: z
|
|
108
|
+
.object({
|
|
109
|
+
origin: z.union([z.string(), z.array(z.string()), z.boolean()]).optional(),
|
|
110
|
+
credentials: z.boolean().optional(),
|
|
111
|
+
methods: z.array(z.string()).optional(),
|
|
112
|
+
allowedHeaders: z.array(z.string()).optional(),
|
|
113
|
+
exposedHeaders: z.array(z.string()).optional(),
|
|
114
|
+
maxAge: z.number().int().optional()
|
|
115
|
+
})
|
|
116
|
+
.optional(),
|
|
92
117
|
// Simulation (optional overrides)
|
|
93
118
|
delayMs: z.number().int().min(0).optional(),
|
|
119
|
+
delay: DelaySchema.optional(),
|
|
94
120
|
errorRate: z.number().min(0).max(1).optional(),
|
|
95
121
|
errorStatus: z.number().int().min(100).max(599).optional(),
|
|
96
122
|
errorResponse: TemplateValueSchema.optional()
|
|
@@ -103,7 +129,17 @@ exports.MockSpecSchema = z.object({
|
|
|
103
129
|
errorRate: z.number().min(0).max(1).default(0),
|
|
104
130
|
errorStatus: z.number().int().min(100).max(599).default(500),
|
|
105
131
|
errorResponse: TemplateValueSchema.default({ error: "Mock error" }),
|
|
106
|
-
fakerSeed: z.number().int().min(0).optional()
|
|
132
|
+
fakerSeed: z.number().int().min(0).optional(),
|
|
133
|
+
cors: z
|
|
134
|
+
.object({
|
|
135
|
+
origin: z.union([z.string(), z.array(z.string()), z.boolean()]).optional(),
|
|
136
|
+
credentials: z.boolean().optional(),
|
|
137
|
+
methods: z.array(z.string()).optional(),
|
|
138
|
+
allowedHeaders: z.array(z.string()).optional(),
|
|
139
|
+
exposedHeaders: z.array(z.string()).optional(),
|
|
140
|
+
maxAge: z.number().int().optional()
|
|
141
|
+
})
|
|
142
|
+
.optional()
|
|
107
143
|
})
|
|
108
144
|
.default({ delayMs: 0, errorRate: 0, errorStatus: 500, errorResponse: { error: "Mock error" } }),
|
|
109
145
|
endpoints: z.array(exports.EndpointSchema).min(1)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "api-json-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -30,9 +30,14 @@
|
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@faker-js/faker": "^10.2.0",
|
|
33
|
+
"@fastify/cookie": "^11.0.2",
|
|
34
|
+
"@fastify/cors": "^11.2.0",
|
|
35
|
+
"@fastify/http-proxy": "^11.4.1",
|
|
33
36
|
"@fastify/static": "^9.0.0",
|
|
37
|
+
"chalk": "^5.6.2",
|
|
34
38
|
"commander": "^14.0.2",
|
|
35
39
|
"fastify": "^5.7.1",
|
|
40
|
+
"pino-pretty": "^13.1.3",
|
|
36
41
|
"swagger-ui-dist": "^5.31.0",
|
|
37
42
|
"yaml": "^2.8.2",
|
|
38
43
|
"zod": "^4.3.5"
|