@xirconsss/zero-mock 0.2.3 → 1.1.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 +3 -3
- package/dist/__tests__/basic.test.js +8 -0
- package/dist/index.js +27 -20
- package/dist/server/app.js +6 -2
- package/dist/server/routes/dynamicRouter.js +99 -9
- package/dist/store/jsonStore.js +26 -0
- package/package.json +23 -5
package/README.md
CHANGED
|
@@ -122,11 +122,11 @@ The root must be a JSON **object**. Each property must be an **array** (your “
|
|
|
122
122
|
|
|
123
123
|
## Publishing to npm (maintainers)
|
|
124
124
|
|
|
125
|
-
**Release flow (recommended):**
|
|
125
|
+
**Release flow (recommended):** Automated via `semantic-release`. Commits following the conventional commit format (e.g., `feat:`, `fix:`) pushed to `main` will automatically trigger a version bump, changelog generation, and NPM publish via the [`.github/workflows/publish-npm.yml`](.github/workflows/publish-npm.yml) workflow. Avoid running `npm publish` on your machine.
|
|
126
126
|
|
|
127
127
|
1. Use an [npmjs.com](https://www.npmjs.com/) account with **2FA** enabled and permission to publish the **`@xirconsss`** scope (user or org on npm).
|
|
128
128
|
2. **GitHub:** repo → **Settings** → **Environments** → **`NPM_TOKEN`** → add secret **`NPM_TOKEN`** (see token steps below). The workflow uses that environment on each run.
|
|
129
|
-
3.
|
|
129
|
+
3. Push conventional commits to **`main`**, wait for **Publish to npm** to finish. Check with `npm view @xirconsss/zero-mock version`.
|
|
130
130
|
|
|
131
131
|
**Token on npm (required for CI):**
|
|
132
132
|
|
|
@@ -143,4 +143,4 @@ MIT - see [LICENSE](LICENSE).
|
|
|
143
143
|
## Links
|
|
144
144
|
|
|
145
145
|
- **Repository:** [github.com/xircons/zero-mock](https://github.com/xircons/zero-mock)
|
|
146
|
-
- **Issues:** [github.com/xircons/zero-mock/issues](https://github.com/xircons/zero-mock/issues)
|
|
146
|
+
- **Issues:** [github.com/xircons/zero-mock/issues](https://github.com/xircons/zero-mock/issues)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
(0, vitest_1.describe)('Environment Check', () => {
|
|
5
|
+
(0, vitest_1.it)('should pass a basic truthy test', () => {
|
|
6
|
+
(0, vitest_1.expect)(true).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
});
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
3
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
-
const
|
|
7
|
+
const chokidar_1 = __importDefault(require("chokidar"));
|
|
5
8
|
const commander_1 = require("commander");
|
|
6
9
|
const jsonStore_1 = require("./store/jsonStore");
|
|
7
10
|
const bootstrap_1 = require("./server/bootstrap");
|
|
@@ -13,33 +16,29 @@ function parseDelayMs(raw) {
|
|
|
13
16
|
return Number.parseInt(trimmed, 10);
|
|
14
17
|
}
|
|
15
18
|
function startFileWatcher(filePath) {
|
|
16
|
-
let
|
|
19
|
+
let reloadTimeout = null;
|
|
17
20
|
const reload = async () => {
|
|
18
|
-
if (reloadInFlight) {
|
|
19
|
-
return;
|
|
20
|
-
}
|
|
21
|
-
reloadInFlight = true;
|
|
22
21
|
try {
|
|
23
22
|
await jsonStore_1.JsonStore.load(filePath);
|
|
23
|
+
console.log(`[watch] Reloaded "${filePath}".`);
|
|
24
24
|
}
|
|
25
25
|
catch (err) {
|
|
26
26
|
const message = err instanceof Error ? err.message : String(err);
|
|
27
27
|
console.error(`[watch] Could not reload "${filePath}": ${message}`);
|
|
28
28
|
}
|
|
29
|
-
finally {
|
|
30
|
-
reloadInFlight = false;
|
|
31
|
-
}
|
|
32
29
|
};
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
30
|
+
chokidar_1.default
|
|
31
|
+
.watch(filePath, { persistent: true, ignoreInitial: true })
|
|
32
|
+
.on("change", () => {
|
|
33
|
+
if (reloadTimeout)
|
|
34
|
+
clearTimeout(reloadTimeout);
|
|
35
|
+
reloadTimeout = setTimeout(() => void reload(), 100);
|
|
36
|
+
})
|
|
37
|
+
.on("error", (err) => {
|
|
38
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
39
|
+
console.error(`[watch] Watcher error: ${msg}`);
|
|
40
|
+
});
|
|
41
|
+
console.log(`[watch] Watching "${filePath}" for changes.`);
|
|
43
42
|
}
|
|
44
43
|
const program = new commander_1.Command();
|
|
45
44
|
program
|
|
@@ -49,6 +48,9 @@ program
|
|
|
49
48
|
.option("-p, --port <number>", "HTTP port", "3000")
|
|
50
49
|
.option("-d, --delay <ms>", "delay each request by this many ms (0 = off)", "0")
|
|
51
50
|
.option("-w, --watch", "reload JSON from disk when the file changes", false)
|
|
51
|
+
.option("--cors-origin <origins>", "comma-separated list of allowed origins (e.g., http://localhost:3000)", "*")
|
|
52
|
+
.option("--cors-methods <methods>", "comma-separated list of allowed HTTP methods", "GET,HEAD,PUT,PATCH,POST,DELETE")
|
|
53
|
+
.option("--cors-credentials", "enable CORS credentials (cookies, authorization headers)", false)
|
|
52
54
|
.action(async (opts) => {
|
|
53
55
|
const port = Number.parseInt(opts.port, 10);
|
|
54
56
|
if (Number.isNaN(port) || port < 1 || port > 65535) {
|
|
@@ -73,7 +75,12 @@ program
|
|
|
73
75
|
return;
|
|
74
76
|
}
|
|
75
77
|
try {
|
|
76
|
-
await (0, bootstrap_1.bootstrap)(port, {
|
|
78
|
+
await (0, bootstrap_1.bootstrap)(port, {
|
|
79
|
+
delayMs,
|
|
80
|
+
corsOrigin: opts.corsOrigin,
|
|
81
|
+
corsMethods: opts.corsMethods,
|
|
82
|
+
corsCredentials: opts.corsCredentials,
|
|
83
|
+
});
|
|
77
84
|
}
|
|
78
85
|
catch (err) {
|
|
79
86
|
const message = err instanceof Error ? err.message : String(err);
|
package/dist/server/app.js
CHANGED
|
@@ -12,7 +12,7 @@ const errorHandler = (err, _req, res, _next) => {
|
|
|
12
12
|
return;
|
|
13
13
|
}
|
|
14
14
|
const message = err instanceof Error ? err.message : String(err);
|
|
15
|
-
res.status(500).json({ error: message });
|
|
15
|
+
res.status(500).json({ error: "INTERNAL_SERVER_ERROR", message, statusCode: 500 });
|
|
16
16
|
};
|
|
17
17
|
function requestLoggingMiddleware(req, res, next) {
|
|
18
18
|
res.on("finish", () => {
|
|
@@ -31,7 +31,11 @@ function delayMiddleware(delayMs) {
|
|
|
31
31
|
}
|
|
32
32
|
function createApp(options) {
|
|
33
33
|
const app = (0, express_1.default)();
|
|
34
|
-
app.use((0, cors_1.default)(
|
|
34
|
+
app.use((0, cors_1.default)({
|
|
35
|
+
origin: options.corsOrigin && options.corsOrigin !== "*" ? options.corsOrigin.split(",") : "*",
|
|
36
|
+
methods: options.corsMethods || "GET,HEAD,PUT,PATCH,POST,DELETE",
|
|
37
|
+
credentials: options.corsCredentials || false,
|
|
38
|
+
}));
|
|
35
39
|
app.use(express_1.default.json());
|
|
36
40
|
app.use(requestLoggingMiddleware);
|
|
37
41
|
app.use(delayMiddleware(options.delayMs));
|
|
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.buildDynamicRouter = buildDynamicRouter;
|
|
7
7
|
const crypto_1 = require("crypto");
|
|
8
8
|
const express_1 = __importDefault(require("express"));
|
|
9
|
+
const zod_1 = require("zod");
|
|
9
10
|
const jsonStore_1 = require("../../store/jsonStore");
|
|
10
11
|
function isPlainObject(value) {
|
|
11
12
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -44,27 +45,73 @@ function parsePagination(query) {
|
|
|
44
45
|
return { page, limit };
|
|
45
46
|
}
|
|
46
47
|
function filterCollection(items, query) {
|
|
47
|
-
|
|
48
|
+
let result = items;
|
|
49
|
+
// Sorting
|
|
50
|
+
const sort = firstQueryValue(query["_sort"]);
|
|
51
|
+
const order = firstQueryValue(query["_order"])?.toLowerCase() === "desc" ? -1 : 1;
|
|
52
|
+
if (sort) {
|
|
53
|
+
result = [...result].sort((a, b) => {
|
|
54
|
+
if (!isPlainObject(a) || !isPlainObject(b))
|
|
55
|
+
return 0;
|
|
56
|
+
const valA = a[sort];
|
|
57
|
+
const valB = b[sort];
|
|
58
|
+
if (valA === valB)
|
|
59
|
+
return 0;
|
|
60
|
+
if (valA === undefined)
|
|
61
|
+
return order;
|
|
62
|
+
if (valB === undefined)
|
|
63
|
+
return -order;
|
|
64
|
+
if (typeof valA === "number" && typeof valB === "number")
|
|
65
|
+
return (valA - valB) * order;
|
|
66
|
+
return String(valA).localeCompare(String(valB)) * order;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
const filterEntries = Object.entries(query).filter(([k]) => !["_page", "_limit", "_sort", "_order"].includes(k));
|
|
48
70
|
if (filterEntries.length === 0) {
|
|
49
|
-
return
|
|
71
|
+
return result;
|
|
50
72
|
}
|
|
51
|
-
return
|
|
73
|
+
return result.filter((item) => {
|
|
52
74
|
if (!isPlainObject(item)) {
|
|
53
75
|
return false;
|
|
54
76
|
}
|
|
55
77
|
const rec = item;
|
|
56
78
|
for (const [key, qVal] of filterEntries) {
|
|
57
79
|
const want = firstQueryValue(qVal);
|
|
58
|
-
if (want === undefined)
|
|
80
|
+
if (want === undefined)
|
|
59
81
|
continue;
|
|
82
|
+
let field = key;
|
|
83
|
+
let op = "eq";
|
|
84
|
+
if (key.endsWith("_gte")) {
|
|
85
|
+
field = key.slice(0, -4);
|
|
86
|
+
op = "gte";
|
|
60
87
|
}
|
|
61
|
-
if (
|
|
62
|
-
|
|
88
|
+
else if (key.endsWith("_lte")) {
|
|
89
|
+
field = key.slice(0, -4);
|
|
90
|
+
op = "lte";
|
|
63
91
|
}
|
|
64
|
-
if (
|
|
65
|
-
|
|
92
|
+
else if (key.endsWith("_like")) {
|
|
93
|
+
field = key.slice(0, -5);
|
|
94
|
+
op = "like";
|
|
95
|
+
}
|
|
96
|
+
const actual = rec[field];
|
|
97
|
+
if (op === "eq") {
|
|
98
|
+
if (actual != want)
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
else if (op === "gte") {
|
|
102
|
+
if (Number(actual) < Number(want))
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
else if (op === "lte") {
|
|
106
|
+
if (Number(actual) > Number(want))
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
else if (op === "like") {
|
|
110
|
+
if (actual === undefined || actual === null)
|
|
111
|
+
return false;
|
|
112
|
+
if (!String(actual).toLowerCase().includes(String(want).toLowerCase()))
|
|
113
|
+
return false;
|
|
66
114
|
}
|
|
67
|
-
return false;
|
|
68
115
|
}
|
|
69
116
|
return true;
|
|
70
117
|
});
|
|
@@ -98,6 +145,43 @@ function itemNotFound(res, resource, id) {
|
|
|
98
145
|
function badBody(res) {
|
|
99
146
|
res.status(400).json({ error: "Request body must be a JSON object." });
|
|
100
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Infer a zod schema from the first item in a collection.
|
|
150
|
+
* Returns null if the collection is empty (skip validation).
|
|
151
|
+
* The schema is "partial" so that POST/PATCH with missing optional fields still pass —
|
|
152
|
+
* only fields that ARE present are type-checked.
|
|
153
|
+
*/
|
|
154
|
+
function inferSchema(collection) {
|
|
155
|
+
const first = collection.find(isPlainObject);
|
|
156
|
+
if (!first)
|
|
157
|
+
return null;
|
|
158
|
+
const shape = {};
|
|
159
|
+
for (const [key, value] of Object.entries(first)) {
|
|
160
|
+
if (key === "id")
|
|
161
|
+
continue;
|
|
162
|
+
if (typeof value === "string")
|
|
163
|
+
shape[key] = zod_1.z.string();
|
|
164
|
+
else if (typeof value === "number")
|
|
165
|
+
shape[key] = zod_1.z.number();
|
|
166
|
+
else if (typeof value === "boolean")
|
|
167
|
+
shape[key] = zod_1.z.boolean();
|
|
168
|
+
else
|
|
169
|
+
shape[key] = zod_1.z.unknown();
|
|
170
|
+
}
|
|
171
|
+
return zod_1.z.object(shape).partial();
|
|
172
|
+
}
|
|
173
|
+
function validateBody(res, collection, body) {
|
|
174
|
+
const schema = inferSchema(collection);
|
|
175
|
+
if (!schema)
|
|
176
|
+
return true; // empty collection → skip
|
|
177
|
+
const result = schema.safeParse(body);
|
|
178
|
+
if (!result.success) {
|
|
179
|
+
const messages = result.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`);
|
|
180
|
+
res.status(400).json({ error: "Validation failed.", details: messages });
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
101
185
|
function numericIdValue(id) {
|
|
102
186
|
if (typeof id === "number" && Number.isFinite(id) && Number.isInteger(id)) {
|
|
103
187
|
return id;
|
|
@@ -164,6 +248,8 @@ function buildDynamicRouter() {
|
|
|
164
248
|
return;
|
|
165
249
|
}
|
|
166
250
|
const collection = jsonStore_1.JsonStore.getData()[resource];
|
|
251
|
+
if (!validateBody(res, collection, req.body))
|
|
252
|
+
return;
|
|
167
253
|
const newId = nextIdForCollection(collection);
|
|
168
254
|
const rawBody = req.body;
|
|
169
255
|
const rest = { ...rawBody };
|
|
@@ -180,6 +266,8 @@ function buildDynamicRouter() {
|
|
|
180
266
|
}
|
|
181
267
|
const { id: idParam } = req.params;
|
|
182
268
|
const collection = jsonStore_1.JsonStore.getData()[resource];
|
|
269
|
+
if (!validateBody(res, collection, req.body))
|
|
270
|
+
return;
|
|
183
271
|
const index = findIndexById(collection, idParam);
|
|
184
272
|
if (index === -1) {
|
|
185
273
|
itemNotFound(res, resource, idParam);
|
|
@@ -202,6 +290,8 @@ function buildDynamicRouter() {
|
|
|
202
290
|
}
|
|
203
291
|
const { id: idParam } = req.params;
|
|
204
292
|
const collection = jsonStore_1.JsonStore.getData()[resource];
|
|
293
|
+
if (!validateBody(res, collection, req.body))
|
|
294
|
+
return;
|
|
205
295
|
const index = findIndexById(collection, idParam);
|
|
206
296
|
if (index === -1) {
|
|
207
297
|
itemNotFound(res, resource, idParam);
|
package/dist/store/jsonStore.js
CHANGED
|
@@ -31,9 +31,22 @@ function parseJsonStorePayload(raw, filePath) {
|
|
|
31
31
|
class JsonStoreImpl {
|
|
32
32
|
data = {};
|
|
33
33
|
backingPath = null;
|
|
34
|
+
lockPath = null;
|
|
34
35
|
/** Serializes concurrent save() calls so writes are not interleaved. */
|
|
35
36
|
saveChain = Promise.resolve();
|
|
36
37
|
async load(filePath) {
|
|
38
|
+
const lockPath = filePath + ".zero-mock.lock";
|
|
39
|
+
try {
|
|
40
|
+
await (0, promises_1.access)(lockPath);
|
|
41
|
+
console.warn(`[warn] Lock file found: "${lockPath}". ` +
|
|
42
|
+
`Another zero-mock process may be running on this file. ` +
|
|
43
|
+
`If not, delete the lock file and retry.`);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
await (0, promises_1.writeFile)(lockPath, String(process.pid), "utf8");
|
|
47
|
+
this.lockPath = lockPath;
|
|
48
|
+
this._registerLockCleanup();
|
|
49
|
+
}
|
|
37
50
|
let raw;
|
|
38
51
|
try {
|
|
39
52
|
raw = await (0, promises_1.readFile)(filePath, "utf8");
|
|
@@ -48,6 +61,19 @@ class JsonStoreImpl {
|
|
|
48
61
|
this.data = parseJsonStorePayload(raw, filePath);
|
|
49
62
|
this.backingPath = filePath;
|
|
50
63
|
}
|
|
64
|
+
_registerLockCleanup() {
|
|
65
|
+
const cleanup = () => {
|
|
66
|
+
if (this.lockPath) {
|
|
67
|
+
try {
|
|
68
|
+
require("fs").unlinkSync(this.lockPath);
|
|
69
|
+
}
|
|
70
|
+
catch (_) { }
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
process.once("exit", cleanup);
|
|
74
|
+
process.once("SIGINT", () => { cleanup(); process.exit(130); });
|
|
75
|
+
process.once("SIGTERM", () => { cleanup(); process.exit(143); });
|
|
76
|
+
}
|
|
51
77
|
getData() {
|
|
52
78
|
return this.data;
|
|
53
79
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xirconsss/zero-mock",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Zero-config CLI that generates REST APIs from JSON files",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -29,10 +29,14 @@
|
|
|
29
29
|
"build": "tsc",
|
|
30
30
|
"start": "node dist/index.js",
|
|
31
31
|
"dev": "ts-node src/index.ts",
|
|
32
|
-
"
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"test:coverage": "vitest run --coverage",
|
|
34
|
+
"prepublishOnly": "npm run build",
|
|
35
|
+
"prepare": "husky"
|
|
33
36
|
},
|
|
34
37
|
"publishConfig": {
|
|
35
|
-
"access": "public"
|
|
38
|
+
"access": "public",
|
|
39
|
+
"provenance": true
|
|
36
40
|
},
|
|
37
41
|
"repository": {
|
|
38
42
|
"type": "git",
|
|
@@ -43,15 +47,29 @@
|
|
|
43
47
|
},
|
|
44
48
|
"homepage": "https://github.com/xircons/zero-mock#readme",
|
|
45
49
|
"dependencies": {
|
|
50
|
+
"chokidar": "^4.0.3",
|
|
46
51
|
"commander": "^13.1.0",
|
|
47
52
|
"cors": "^2.8.5",
|
|
48
|
-
"express": "^4.21.2"
|
|
53
|
+
"express": "^4.21.2",
|
|
54
|
+
"zod": "^4.4.3"
|
|
49
55
|
},
|
|
50
56
|
"devDependencies": {
|
|
57
|
+
"@commitlint/cli": "^21.0.2",
|
|
58
|
+
"@commitlint/config-conventional": "^21.0.2",
|
|
59
|
+
"@semantic-release/changelog": "^6.0.3",
|
|
60
|
+
"@semantic-release/commit-analyzer": "^13.0.1",
|
|
61
|
+
"@semantic-release/git": "^10.0.1",
|
|
62
|
+
"@semantic-release/github": "^12.0.8",
|
|
63
|
+
"@semantic-release/npm": "^12.0.2",
|
|
64
|
+
"@semantic-release/release-notes-generator": "^14.1.1",
|
|
51
65
|
"@types/cors": "^2.8.17",
|
|
52
66
|
"@types/express": "^4.17.21",
|
|
53
67
|
"@types/node": "^22.14.0",
|
|
68
|
+
"@vitest/coverage-v8": "^2.1.9",
|
|
69
|
+
"husky": "^9.1.7",
|
|
70
|
+
"semantic-release": "^25.0.5",
|
|
54
71
|
"ts-node": "^10.9.2",
|
|
55
|
-
"typescript": "^5.8.2"
|
|
72
|
+
"typescript": "^5.8.2",
|
|
73
|
+
"vitest": "^2.1.9"
|
|
56
74
|
}
|
|
57
75
|
}
|