@xirconsss/zero-mock 0.1.1 → 0.2.1

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 CHANGED
@@ -6,6 +6,10 @@
6
6
 
7
7
  **zero-mock** is a zero-config Node.js CLI that turns a JSON file into a local REST API. Point it at a file whose top-level keys are **collection names** and whose values are **arrays of records**—it serves full CRUD routes and writes changes back to disk. Built for **frontend developers** who need a quick, realistic backend for prototypes, demos, and integration tests without standing up a database or bespoke server.
8
8
 
9
+ ## Demo
10
+
11
+ ![Terminal demo](docs/demo.gif)
12
+
9
13
  ## Installation
10
14
 
11
15
  ### Global
@@ -19,10 +23,26 @@ Run the CLI as **`zero-mock`** (see [Usage](#usage)).
19
23
  ### One-off with npx
20
24
 
21
25
  ```bash
22
- npx @xirconsss/zero-mock -f ./data.json -p 3000
26
+ npx @xirconsss/zero-mock -f ./example/db.json -p 3000
23
27
  ```
24
28
 
25
- Flags: **`-f`** / **`--file`** (required JSON path), **`-p`** / **`--port`** (optional, default `3000`). If your shell or npm version forwards extra flags to npm instead of the CLI, insert **`--`** before **`-f`** (e.g. `npx @xirconsss/zero-mock -- -f ./data.json`).
29
+ If your shell or npm version forwards extra flags to npm instead of the CLI, insert **`--`** before the first CLI flag (e.g. `npx @xirconsss/zero-mock -- -f ./data.json -p 3000 -w`).
30
+
31
+ ## Usage
32
+
33
+ | Flag | Description |
34
+ | ---- | ----------- |
35
+ | **`-f` / `--file`** | Required. Path to the JSON file. |
36
+ | **`-p` / `--port`** | HTTP port (default `3000`, must be 1–65535). |
37
+ | **`-d` / `--delay`** | Optional. Delay every request by this many milliseconds. Must be a non-negative integer using digits only (default `0`). |
38
+ | **`-w` / `--watch`** | Optional. Watch the JSON file and reload the in-memory data when it changes. If a save produces invalid JSON, the server prints `[watch] Could not reload "<path>": ...` to stderr and keeps the last good data until the file is valid again. Only one reload runs at a time. When watch starts, you also get `[watch] Watching "<path>" for changes.` on stdout. |
39
+
40
+ Examples:
41
+
42
+ ```bash
43
+ zero-mock -f ./data.json -p 3000 -d 200
44
+ zero-mock -f ./data.json -w
45
+ ```
26
46
 
27
47
  ## Quick start
28
48
 
@@ -32,6 +52,12 @@ From the repo root (or any directory containing the example file):
32
52
  npx @xirconsss/zero-mock -f ./example/db.json -p 3000
33
53
  ```
34
54
 
55
+ Optional:
56
+
57
+ ```bash
58
+ npx @xirconsss/zero-mock -f ./example/db.json -p 3000 -d 200 -w
59
+ ```
60
+
35
61
  In another terminal:
36
62
 
37
63
  ```bash
@@ -43,7 +69,13 @@ The server logs the exact URLs for each collection when it starts.
43
69
 
44
70
  ## Development
45
71
 
46
- From a clone: `npm install`, then `npm run build` (or `npm run dev` with `ts-node`). Run the built CLI with `node dist/index.js -f ./example/db.json -p 3000`.
72
+ From a clone: `npm install`, then `npm run build` (or `npm run dev` with `ts-node`). Run the built CLI with:
73
+
74
+ ```bash
75
+ node dist/index.js -f ./example/db.json -p 3000
76
+ ```
77
+
78
+ Add `-d` / `-w` the same way as the published CLI.
47
79
 
48
80
  ## Features
49
81
 
@@ -51,6 +83,10 @@ From a clone: `npm install`, then `npm run build` (or `npm run dev` with `ts-nod
51
83
  - **Full CRUD REST API** — `GET`, `POST`, `PUT`, `PATCH`, and `DELETE` per collection, with CORS and JSON bodies enabled.
52
84
  - **Atomic file persistence** — writes go through a temp file and rename, with serialized saves so concurrent requests do not corrupt the file.
53
85
  - **Smart ID generation** — new rows get the next **numeric** id when existing ids are integers or all-digit strings; otherwise new ids use a **UUID**.
86
+ - **Request logging** — each finished request logs as `[METHOD] <path> - <status>` (path is Express `req.path`, no query string).
87
+ - **Optional delay** — `-d` adds a fixed pause before route handling (after JSON body parsing).
88
+ - **List filtering and pagination** — see [List GET](#list-get) on `GET /{resource}`.
89
+ - **Watch mode** — `-w` reloads data from disk on file change without restarting the process (see [Usage](#usage)).
54
90
 
55
91
  ## Auto-generated API
56
92
 
@@ -58,7 +94,7 @@ Each **top-level key** in your JSON (e.g. `users`, `posts`) becomes a **resource
58
94
 
59
95
  | Method | Path | Description |
60
96
  | -------- | -------------------- | ----------- |
61
- | `GET` | `/{resource}` | List all items in the collection. |
97
+ | `GET` | `/{resource}` | List items (optionally [filtered and paginated](#list-get)). |
62
98
  | `POST` | `/{resource}` | Create an item; server assigns `id` and returns `201` with the new body. |
63
99
  | `GET` | `/{resource}/{id}` | Return one item by `id`; `404` if missing. |
64
100
  | `PUT` | `/{resource}/{id}` | Replace the item; `id` in the URL wins; `404` if missing. |
@@ -67,6 +103,19 @@ Each **top-level key** in your JSON (e.g. `users`, `posts`) becomes a **resource
67
103
 
68
104
  Invalid JSON bodies (non-objects for write routes) receive **`400`** with a JSON error message. Persistence failures surface as **`500`**.
69
105
 
106
+ ### List GET
107
+
108
+ **Filtering:** Every query parameter except `_page` and `_limit` is a filter. Only plain-object rows are kept. For each filter key, the row must have that property, and the value must match the query value with loose equality (`==`). Query values are strings (first value wins if repeated). Rows missing a filter key are dropped.
109
+
110
+ **Pagination:** If **both** `_page` and `_limit` are present and are positive integers (digit strings only), the list is sliced after filtering. `_page` is 1-based. If either is missing or invalid, the full filtered list is returned (no error).
111
+
112
+ Examples using [example/db.json](example/db.json):
113
+
114
+ ```bash
115
+ curl 'http://localhost:3000/users?role=admin'
116
+ curl 'http://localhost:3000/users?_page=1&_limit=2'
117
+ ```
118
+
70
119
  ## JSON file shape
71
120
 
72
121
  The root must be a JSON **object**. Each property must be an **array** (your “tables”). Each item you want to address by URL should include an **`id`** field (number or string).
@@ -91,6 +140,7 @@ The root must be a JSON **object**. Each property must be an **array** (your “
91
140
  ## License
92
141
 
93
142
  MIT - see [LICENSE](LICENSE).
143
+
94
144
  ## Links
95
145
 
96
146
  - **Repository:** [github.com/xircons/zero-mock](https://github.com/xircons/zero-mock)
package/dist/index.js CHANGED
@@ -1,15 +1,54 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
+ const fs_1 = require("fs");
4
5
  const commander_1 = require("commander");
5
6
  const jsonStore_1 = require("./store/jsonStore");
6
7
  const bootstrap_1 = require("./server/bootstrap");
8
+ function parseDelayMs(raw) {
9
+ const trimmed = raw.trim();
10
+ if (!/^\d+$/.test(trimmed)) {
11
+ return null;
12
+ }
13
+ return Number.parseInt(trimmed, 10);
14
+ }
15
+ function startFileWatcher(filePath) {
16
+ let reloadInFlight = false;
17
+ const reload = async () => {
18
+ if (reloadInFlight) {
19
+ return;
20
+ }
21
+ reloadInFlight = true;
22
+ try {
23
+ await jsonStore_1.JsonStore.load(filePath);
24
+ }
25
+ catch (err) {
26
+ const message = err instanceof Error ? err.message : String(err);
27
+ console.error(`[watch] Could not reload "${filePath}": ${message}`);
28
+ }
29
+ finally {
30
+ reloadInFlight = false;
31
+ }
32
+ };
33
+ try {
34
+ (0, fs_1.watch)(filePath, { persistent: true }, () => {
35
+ void reload();
36
+ });
37
+ console.log(`[watch] Watching "${filePath}" for changes.`);
38
+ }
39
+ catch (err) {
40
+ const message = err instanceof Error ? err.message : String(err);
41
+ console.error(`[watch] Failed to watch "${filePath}": ${message}`);
42
+ }
43
+ }
7
44
  const program = new commander_1.Command();
8
45
  program
9
46
  .name("zero-mock")
10
47
  .description("Generate a REST API from a JSON file")
11
48
  .requiredOption("-f, --file <path>", "path to the source JSON file")
12
49
  .option("-p, --port <number>", "HTTP port", "3000")
50
+ .option("-d, --delay <ms>", "delay each request by this many ms (0 = off)", "0")
51
+ .option("-w, --watch", "reload JSON from disk when the file changes", false)
13
52
  .action(async (opts) => {
14
53
  const port = Number.parseInt(opts.port, 10);
15
54
  if (Number.isNaN(port) || port < 1 || port > 65535) {
@@ -17,6 +56,12 @@ program
17
56
  process.exit(1);
18
57
  return;
19
58
  }
59
+ const delayMs = parseDelayMs(opts.delay);
60
+ if (delayMs === null) {
61
+ console.error("error: --delay must be a non-negative integer");
62
+ process.exit(1);
63
+ return;
64
+ }
20
65
  const filePath = opts.file;
21
66
  try {
22
67
  await jsonStore_1.JsonStore.load(filePath);
@@ -28,12 +73,16 @@ program
28
73
  return;
29
74
  }
30
75
  try {
31
- await (0, bootstrap_1.bootstrap)(port);
76
+ await (0, bootstrap_1.bootstrap)(port, { delayMs });
32
77
  }
33
78
  catch (err) {
34
79
  const message = err instanceof Error ? err.message : String(err);
35
80
  console.error(`Failed to start server: ${message}`);
36
81
  process.exit(1);
82
+ return;
83
+ }
84
+ if (opts.watch) {
85
+ startFileWatcher(filePath);
37
86
  }
38
87
  });
39
88
  program.parse(process.argv);
@@ -14,10 +14,27 @@ const errorHandler = (err, _req, res, _next) => {
14
14
  const message = err instanceof Error ? err.message : String(err);
15
15
  res.status(500).json({ error: message });
16
16
  };
17
- function createApp() {
17
+ function requestLoggingMiddleware(req, res, next) {
18
+ res.on("finish", () => {
19
+ console.log(`[${req.method}] ${req.path} - ${res.statusCode}`);
20
+ });
21
+ next();
22
+ }
23
+ function delayMiddleware(delayMs) {
24
+ return (_req, _res, next) => {
25
+ if (delayMs <= 0) {
26
+ next();
27
+ return;
28
+ }
29
+ setTimeout(() => next(), delayMs);
30
+ };
31
+ }
32
+ function createApp(options) {
18
33
  const app = (0, express_1.default)();
19
34
  app.use((0, cors_1.default)());
20
35
  app.use(express_1.default.json());
36
+ app.use(requestLoggingMiddleware);
37
+ app.use(delayMiddleware(options.delayMs));
21
38
  app.use("/", (0, dynamicRouter_1.buildDynamicRouter)());
22
39
  app.use(errorHandler);
23
40
  return app;
@@ -3,8 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.bootstrap = bootstrap;
4
4
  const app_1 = require("./app");
5
5
  const jsonStore_1 = require("../store/jsonStore");
6
- async function bootstrap(port) {
7
- const app = (0, app_1.createApp)();
6
+ async function bootstrap(port, appOptions) {
7
+ const app = (0, app_1.createApp)(appOptions);
8
8
  await new Promise((resolve, reject) => {
9
9
  const server = app.listen(port, () => {
10
10
  resolve();
@@ -10,6 +10,65 @@ const jsonStore_1 = require("../../store/jsonStore");
10
10
  function isPlainObject(value) {
11
11
  return typeof value === "object" && value !== null && !Array.isArray(value);
12
12
  }
13
+ function firstQueryValue(value) {
14
+ if (value === undefined || value === null) {
15
+ return undefined;
16
+ }
17
+ if (Array.isArray(value)) {
18
+ return firstQueryValue(value[0]);
19
+ }
20
+ if (typeof value === "string") {
21
+ return value;
22
+ }
23
+ if (typeof value === "object") {
24
+ return undefined;
25
+ }
26
+ return String(value);
27
+ }
28
+ function parsePagination(query) {
29
+ const pageRaw = firstQueryValue(query["_page"]);
30
+ const limitRaw = firstQueryValue(query["_limit"]);
31
+ if (pageRaw === undefined || limitRaw === undefined) {
32
+ return null;
33
+ }
34
+ const pageTrim = pageRaw.trim();
35
+ const limitTrim = limitRaw.trim();
36
+ if (!/^\d+$/.test(pageTrim) || !/^\d+$/.test(limitTrim)) {
37
+ return null;
38
+ }
39
+ const page = Number.parseInt(pageTrim, 10);
40
+ const limit = Number.parseInt(limitTrim, 10);
41
+ if (page < 1 || limit < 1) {
42
+ return null;
43
+ }
44
+ return { page, limit };
45
+ }
46
+ function filterCollection(items, query) {
47
+ const filterEntries = Object.entries(query).filter(([k]) => k !== "_page" && k !== "_limit");
48
+ if (filterEntries.length === 0) {
49
+ return items;
50
+ }
51
+ return items.filter((item) => {
52
+ if (!isPlainObject(item)) {
53
+ return false;
54
+ }
55
+ const rec = item;
56
+ for (const [key, qVal] of filterEntries) {
57
+ const want = firstQueryValue(qVal);
58
+ if (want === undefined) {
59
+ continue;
60
+ }
61
+ if (!Object.prototype.hasOwnProperty.call(rec, key)) {
62
+ return false;
63
+ }
64
+ if (rec[key] == want) {
65
+ continue;
66
+ }
67
+ return false;
68
+ }
69
+ return true;
70
+ });
71
+ }
13
72
  function isRecordWithId(item) {
14
73
  if (item === null || typeof item !== "object" || Array.isArray(item)) {
15
74
  return false;
@@ -88,8 +147,16 @@ function buildDynamicRouter() {
88
147
  }
89
148
  res.json(item);
90
149
  }));
91
- router.get(`/${resource}`, (_req, res) => {
92
- res.json(jsonStore_1.JsonStore.getData()[resource]);
150
+ router.get(`/${resource}`, (req, res) => {
151
+ const collection = jsonStore_1.JsonStore.getData()[resource];
152
+ const query = req.query;
153
+ let rows = filterCollection(collection, query);
154
+ const pageInfo = parsePagination(query);
155
+ if (pageInfo !== null) {
156
+ const start = (pageInfo.page - 1) * pageInfo.limit;
157
+ rows = rows.slice(start, start + pageInfo.limit);
158
+ }
159
+ res.json(rows);
93
160
  });
94
161
  router.post(`/${resource}`, asyncHandler(async (req, res) => {
95
162
  if (!isPlainObject(req.body)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xirconsss/zero-mock",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Zero-config CLI that generates REST APIs from JSON files",
5
5
  "license": "MIT",
6
6
  "keywords": [