@xirconsss/zero-mock 1.1.0 → 1.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 +80 -78
- package/dist/cleanup.js +61 -0
- package/dist/cli-wizard.js +151 -0
- package/dist/config-store.js +85 -0
- package/dist/errors.js +119 -0
- package/dist/index.js +109 -39
- package/dist/server/app.js +2 -7
- package/dist/server/bootstrap.js +14 -10
- package/dist/server/logger.js +78 -0
- package/dist/server/routes/dynamicRouter.js +15 -1
- package/dist/store/jsonStore.js +53 -56
- package/package.json +5 -1
- package/example/db.json +0 -45
package/README.md
CHANGED
|
@@ -4,11 +4,22 @@
|
|
|
4
4
|
[](tsconfig.json)
|
|
5
5
|
[](https://www.npmjs.com/package/@xirconsss/zero-mock)
|
|
6
6
|
|
|
7
|
-
**zero-mock** is a zero-config Node.js CLI that turns
|
|
7
|
+
**zero-mock** is a zero-config Node.js CLI that turns any JSON file into a persistent, production-grade REST API in seconds. It includes an interactive setup wizard, a high-contrast brutalist UI, and smart schema inference with automatic validation.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Built for frontend developers and architects who need a realistic, stable backend for prototypes, demos, and integration tests without the overhead of database orchestration.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
## Key Features
|
|
12
|
+
|
|
13
|
+
- **Zero-config** — your JSON file is your database and API definition.
|
|
14
|
+
- **Interactive wizard** — run `zero-mock` to start the setup guide.
|
|
15
|
+
- **Smart validation** — automatically infers Zod schemas from your data to validate `POST`, `PUT`, and `PATCH` requests.
|
|
16
|
+
- **Atomic and secure** — cross-platform atomic writes (Windows-safe) with file-locking to prevent data corruption.
|
|
17
|
+
- **Advanced REST** — supports filtering (`_gte`, `_lte`, `_like`), sorting (`_sort`, `_order`), and pagination (`_page`, `_limit`).
|
|
18
|
+
- **Realistic simulation** — simulate network latency and configure custom CORS origins/methods.
|
|
19
|
+
- **Watch mode** — hot-reloads data from disk on manual file changes without a server restart.
|
|
20
|
+
- **Session memory** — remembers your last used configuration for a faster workflow.
|
|
21
|
+
|
|
22
|
+
---
|
|
12
23
|
|
|
13
24
|
## Installation
|
|
14
25
|
|
|
@@ -18,127 +29,118 @@
|
|
|
18
29
|
npm install -g @xirconsss/zero-mock
|
|
19
30
|
```
|
|
20
31
|
|
|
21
|
-
Run the CLI as
|
|
32
|
+
Run the CLI as `zero-mock`.
|
|
22
33
|
|
|
23
34
|
### One-off with npx
|
|
24
35
|
|
|
25
36
|
```bash
|
|
26
|
-
npx @xirconsss/zero-mock
|
|
37
|
+
npx @xirconsss/zero-mock
|
|
27
38
|
```
|
|
28
39
|
|
|
29
|
-
|
|
40
|
+
---
|
|
30
41
|
|
|
31
42
|
## Usage
|
|
32
43
|
|
|
33
|
-
|
|
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
|
-
```
|
|
46
|
-
|
|
47
|
-
## Quick start
|
|
44
|
+
### 1. Interactive mode (recommended)
|
|
48
45
|
|
|
49
|
-
|
|
46
|
+
Run the command without arguments to launch the setup wizard:
|
|
50
47
|
|
|
51
48
|
```bash
|
|
52
|
-
|
|
49
|
+
zero-mock
|
|
53
50
|
```
|
|
54
51
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
```bash
|
|
58
|
-
npx @xirconsss/zero-mock -f ./example/db.json -p 3000 -d 200 -w
|
|
59
|
-
```
|
|
52
|
+
### 2. Manual CLI
|
|
60
53
|
|
|
61
|
-
|
|
54
|
+
Pass flags to skip the wizard:
|
|
62
55
|
|
|
63
56
|
```bash
|
|
64
|
-
|
|
65
|
-
curl http://localhost:3000/users/1
|
|
57
|
+
zero-mock -f ./data.json -p 8080 -w -d 200
|
|
66
58
|
```
|
|
67
59
|
|
|
68
|
-
|
|
60
|
+
| Flag | Description |
|
|
61
|
+
| ---- | ----------- |
|
|
62
|
+
| `-f`, `--file` | Path to the source JSON file. |
|
|
63
|
+
| `-p`, `--port` | HTTP port (default `3000`, must be 1024–65535). |
|
|
64
|
+
| `-d`, `--delay` | Delay every request by X milliseconds (default `0`). |
|
|
65
|
+
| `-w`, `--watch` | Enable hot-reloading on manual file changes. |
|
|
66
|
+
| `--cors-origin` | Comma-separated allowed origins (default `*`). |
|
|
67
|
+
| `--reset` | Clear saved wizard configuration and exit. |
|
|
69
68
|
|
|
70
|
-
|
|
69
|
+
---
|
|
71
70
|
|
|
72
|
-
|
|
71
|
+
## Auto-generated API
|
|
73
72
|
|
|
74
|
-
|
|
75
|
-
node dist/index.js -f ./example/db.json -p 3000
|
|
76
|
-
```
|
|
73
|
+
The tool generates full CRUD endpoints for every top-level key in your JSON (e.g., `users`, `posts`).
|
|
77
74
|
|
|
78
|
-
|
|
75
|
+
| Method | Path | Description |
|
|
76
|
+
| ------ | ---- | ----------- |
|
|
77
|
+
| `GET` | `/{resource}` | List items (supports filtering, sorting, paging). |
|
|
78
|
+
| `POST` | `/{resource}` | Create item (auto-id + schema validation). |
|
|
79
|
+
| `GET` | `/{resource}/{id}` | Return a single item by `id`. |
|
|
80
|
+
| `PUT` | `/{resource}/{id}` | Replace item (schema validation). |
|
|
81
|
+
| `PATCH` | `/{resource}/{id}` | Partial update (schema validation). |
|
|
82
|
+
| `DELETE` | `/{resource}/{id}` | Remove item. |
|
|
79
83
|
|
|
80
|
-
|
|
84
|
+
### Advanced List Features
|
|
81
85
|
|
|
82
|
-
|
|
83
|
-
- **Full CRUD REST API** — `GET`, `POST`, `PUT`, `PATCH`, and `DELETE` per collection, with CORS and JSON bodies enabled.
|
|
84
|
-
- **Atomic file persistence** — writes go through a temp file and rename, with serialized saves so concurrent requests do not corrupt the file.
|
|
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)).
|
|
86
|
+
**Filtering**
|
|
90
87
|
|
|
91
|
-
|
|
88
|
+
- `GET /posts?category=tech` — exact match
|
|
89
|
+
- `GET /posts?views_gte=100` — greater than or equal
|
|
90
|
+
- `GET /posts?title_like=hello` — case-insensitive search
|
|
92
91
|
|
|
93
|
-
|
|
92
|
+
**Sorting**
|
|
94
93
|
|
|
95
|
-
|
|
96
|
-
| -------- | -------------------- | ----------- |
|
|
97
|
-
| `GET` | `/{resource}` | List items (optionally [filtered and paginated](#list-get)). |
|
|
98
|
-
| `POST` | `/{resource}` | Create an item; server assigns `id` and returns `201` with the new body. |
|
|
99
|
-
| `GET` | `/{resource}/{id}` | Return one item by `id`; `404` if missing. |
|
|
100
|
-
| `PUT` | `/{resource}/{id}` | Replace the item; `id` in the URL wins; `404` if missing. |
|
|
101
|
-
| `PATCH` | `/{resource}/{id}` | Shallow-merge fields into the item; `404` if missing. |
|
|
102
|
-
| `DELETE` | `/{resource}/{id}` | Remove the item; `204` on success; `404` if missing. |
|
|
94
|
+
- `GET /posts?_sort=createdAt&_order=desc`
|
|
103
95
|
|
|
104
|
-
|
|
96
|
+
**Pagination**
|
|
105
97
|
|
|
106
|
-
|
|
98
|
+
- `GET /posts?_page=1&_limit=10`
|
|
99
|
+
- Returns an `X-Total-Count` header and a wrapped `{ data, pagination }` object.
|
|
107
100
|
|
|
108
|
-
|
|
101
|
+
---
|
|
109
102
|
|
|
110
|
-
|
|
103
|
+
## JSON Structure
|
|
111
104
|
|
|
112
|
-
|
|
105
|
+
The root must be an object, and every resource must be an array.
|
|
113
106
|
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"users": [
|
|
110
|
+
{ "id": 1, "name": "Alice", "role": "admin" },
|
|
111
|
+
{ "id": 2, "name": "Bob" }
|
|
112
|
+
]
|
|
113
|
+
}
|
|
117
114
|
```
|
|
118
115
|
|
|
119
|
-
|
|
116
|
+
Note: zero-mock automatically infers that `role` is an optional string based on the items above.
|
|
120
117
|
|
|
121
|
-
|
|
118
|
+
---
|
|
122
119
|
|
|
123
|
-
##
|
|
120
|
+
## Development
|
|
124
121
|
|
|
125
|
-
|
|
122
|
+
1. Clone the repo.
|
|
123
|
+
2. `npm install`
|
|
124
|
+
3. `npm run dev` (runs with `ts-node`)
|
|
125
|
+
4. `npm run build` (compiles to `dist/`)
|
|
126
126
|
|
|
127
|
-
|
|
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. Push conventional commits to **`main`**, wait for **Publish to npm** to finish. Check with `npm view @xirconsss/zero-mock version`.
|
|
127
|
+
---
|
|
130
128
|
|
|
131
|
-
|
|
129
|
+
## Publishing (Maintainers)
|
|
132
130
|
|
|
133
|
-
|
|
134
|
-
2. For granular tokens: turn **Bypass two-factor authentication (2FA)** **on** so CI does not hit **`EOTP`**. Do **not** use **`NPM_OTP`** secrets (codes expire in ~30 seconds).
|
|
135
|
-
3. Paste the token into the **`NPM_TOKEN`** environment secret on GitHub.
|
|
131
|
+
This project uses `semantic-release` for fully automated versioning and npm publishing.
|
|
136
132
|
|
|
137
|
-
|
|
133
|
+
1. Ensure your commits follow the [Conventional Commits](https://www.conventionalcommits.org/) format (e.g., `feat:`, `fix:`, `chore:`).
|
|
134
|
+
2. Push or open a PR against the `main` branch.
|
|
135
|
+
3. GitHub Actions handles testing, version bumping, changelog generation, and publishing to npm.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
138
|
|
|
139
139
|
## License
|
|
140
140
|
|
|
141
|
-
MIT
|
|
141
|
+
MIT — see [LICENSE](LICENSE).
|
|
142
|
+
|
|
143
|
+
---
|
|
142
144
|
|
|
143
145
|
## Links
|
|
144
146
|
|
package/dist/cleanup.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
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.registerLock = registerLock;
|
|
7
|
+
exports.releaseLock = releaseLock;
|
|
8
|
+
exports.registerCleanupTask = registerCleanupTask;
|
|
9
|
+
const fs_1 = __importDefault(require("fs"));
|
|
10
|
+
const errors_1 = require("./errors");
|
|
11
|
+
const activeLocks = new Set();
|
|
12
|
+
const cleanupTasks = new Set();
|
|
13
|
+
function registerLock(lockFilePath) {
|
|
14
|
+
activeLocks.add(lockFilePath);
|
|
15
|
+
}
|
|
16
|
+
function releaseLock(lockFilePath) {
|
|
17
|
+
activeLocks.delete(lockFilePath);
|
|
18
|
+
try {
|
|
19
|
+
if (fs_1.default.existsSync(lockFilePath)) {
|
|
20
|
+
fs_1.default.unlinkSync(lockFilePath);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch (_) {
|
|
24
|
+
// Ignore cleanup errors
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function registerCleanupTask(task) {
|
|
28
|
+
cleanupTasks.add(task);
|
|
29
|
+
}
|
|
30
|
+
function runCleanup() {
|
|
31
|
+
for (const lockFilePath of activeLocks) {
|
|
32
|
+
try {
|
|
33
|
+
if (fs_1.default.existsSync(lockFilePath)) {
|
|
34
|
+
fs_1.default.unlinkSync(lockFilePath);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch (_) {
|
|
38
|
+
// Ignore cleanup errors
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
activeLocks.clear();
|
|
42
|
+
for (const task of cleanupTasks) {
|
|
43
|
+
try {
|
|
44
|
+
task();
|
|
45
|
+
}
|
|
46
|
+
catch (_) {
|
|
47
|
+
// Ignore cleanup task errors
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
cleanupTasks.clear();
|
|
51
|
+
}
|
|
52
|
+
// Module load time registry
|
|
53
|
+
process.on('exit', runCleanup);
|
|
54
|
+
process.on('SIGINT', () => { runCleanup(); process.exit(0); });
|
|
55
|
+
process.on('SIGTERM', () => { runCleanup(); process.exit(0); });
|
|
56
|
+
process.on('SIGHUP', () => { runCleanup(); process.exit(0); });
|
|
57
|
+
process.on('uncaughtException', (err) => {
|
|
58
|
+
console.error(err);
|
|
59
|
+
runCleanup();
|
|
60
|
+
(0, errors_1.printFatal)('SERVER_CRASH', err.message);
|
|
61
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
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.PANEL_W = void 0;
|
|
7
|
+
exports.stripAnsi = stripAnsi;
|
|
8
|
+
exports.centerPad = centerPad;
|
|
9
|
+
exports.sectionHeader = sectionHeader;
|
|
10
|
+
exports.renderConfigPanel = renderConfigPanel;
|
|
11
|
+
exports.runWizard = runWizard;
|
|
12
|
+
const prompts_1 = require("@inquirer/prompts");
|
|
13
|
+
const picocolors_1 = __importDefault(require("picocolors"));
|
|
14
|
+
const config_store_1 = require("./config-store");
|
|
15
|
+
exports.PANEL_W = 42;
|
|
16
|
+
function stripAnsi(s) {
|
|
17
|
+
return s.replace(/\x1B\[[0-9;]*[mGKHF]/g, '');
|
|
18
|
+
}
|
|
19
|
+
function centerPad(text, width = 42) {
|
|
20
|
+
const stripped = stripAnsi(text);
|
|
21
|
+
const padding = Math.max(0, Math.floor((width - stripped.length) / 2));
|
|
22
|
+
return ' '.repeat(padding) + text;
|
|
23
|
+
}
|
|
24
|
+
function sectionHeader(title) {
|
|
25
|
+
const header = `${picocolors_1.default.blue('◆')} ${picocolors_1.default.bold(picocolors_1.default.white(title))}`;
|
|
26
|
+
const rule = picocolors_1.default.dim('─'.repeat(exports.PANEL_W));
|
|
27
|
+
return `${header}\n${rule}`;
|
|
28
|
+
}
|
|
29
|
+
function renderConfigPanel(config, saved) {
|
|
30
|
+
let out = picocolors_1.default.dim('╭' + '─'.repeat(exports.PANEL_W) + '╮\n');
|
|
31
|
+
const rows = [
|
|
32
|
+
{ label: 'Target File', value: config.file },
|
|
33
|
+
{ label: 'Default Port', value: config.port },
|
|
34
|
+
{ label: 'Latency', value: `${config.delay}ms` },
|
|
35
|
+
{ label: 'Watch Mode', value: config.watch ? 'Active' : 'Inactive' },
|
|
36
|
+
];
|
|
37
|
+
for (const row of rows) {
|
|
38
|
+
const labelPadded = ` · ${row.label} `.padEnd(20, ' ');
|
|
39
|
+
const valRaw = row.value;
|
|
40
|
+
const spaceCount = Math.max(0, exports.PANEL_W - labelPadded.length - valRaw.length - 3);
|
|
41
|
+
const spaces = ' '.repeat(spaceCount);
|
|
42
|
+
out += picocolors_1.default.dim('│') + picocolors_1.default.dim(labelPadded) + picocolors_1.default.white(row.value) + spaces + ' ' + picocolors_1.default.green('✓') + ' ' + picocolors_1.default.dim('│') + '\n';
|
|
43
|
+
}
|
|
44
|
+
if (saved) {
|
|
45
|
+
out += picocolors_1.default.dim('├' + '─'.repeat(exports.PANEL_W) + '┤\n');
|
|
46
|
+
const footerText = `last used ${(0, config_store_1.formatSavedAt)(saved.savedAt)}`;
|
|
47
|
+
const footerPadded = ` · ${footerText} `.padEnd(exports.PANEL_W, ' ');
|
|
48
|
+
out += picocolors_1.default.dim('│') + picocolors_1.default.dim(footerPadded) + picocolors_1.default.dim('│') + '\n';
|
|
49
|
+
}
|
|
50
|
+
out += picocolors_1.default.dim('╰' + '─'.repeat(exports.PANEL_W) + '╯');
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
const hex = (hexCode, text) => {
|
|
54
|
+
const r = parseInt(hexCode.slice(1, 3), 16);
|
|
55
|
+
const g = parseInt(hexCode.slice(3, 5), 16);
|
|
56
|
+
const b = parseInt(hexCode.slice(5, 7), 16);
|
|
57
|
+
return `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`;
|
|
58
|
+
};
|
|
59
|
+
const FALLBACK = {
|
|
60
|
+
file: './example/db.json',
|
|
61
|
+
port: '8080',
|
|
62
|
+
delay: '200',
|
|
63
|
+
watch: true,
|
|
64
|
+
};
|
|
65
|
+
async function runWizard() {
|
|
66
|
+
console.clear();
|
|
67
|
+
const saved = (0, config_store_1.loadSavedConfig)();
|
|
68
|
+
const defaults = saved ?? FALLBACK;
|
|
69
|
+
const isReturning = saved !== null;
|
|
70
|
+
const logoTop = hex('#bfdbfe', ' ▗▄▄▄▄▖▗▄▄▄▖▗▄▄▖ ▗▄▖ ▗▖ ▗▖ ▗▄▖ ▗▄▄▖▗▖ ▗▖');
|
|
71
|
+
const logoMid1 = hex('#60a5fa', ' ▗▞▘▐▌ ▐▌ ▐▌▐▌ ▐▌▐▛▚▞▜▌▐▌ ▐▌▐▌ ▐▌▗▞▘');
|
|
72
|
+
const logoMid2 = hex('#2563eb', ' ▗▞▘ ▐▛▀▀▘▐▛▀▚▖▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▛▚▖ ');
|
|
73
|
+
const logoBottom = hex('#1e3a8a', ' ▐▙▄▄▄▖▐▙▄▄▖▐▌ ▐▌▝▚▄▞▘▐▌ ▐▌▝▚▄▞▘▝▚▄▄▖▐▌ ▐▌');
|
|
74
|
+
console.log(`\n${logoTop}\n${logoMid1}\n${logoMid2}\n${logoBottom}\n`);
|
|
75
|
+
console.log(centerPad(picocolors_1.default.bold(picocolors_1.default.white("Zero-Mock By Xircons"))));
|
|
76
|
+
console.log(centerPad(picocolors_1.default.dim("Zero-config REST API setup in seconds")));
|
|
77
|
+
if (isReturning && saved) {
|
|
78
|
+
console.log(centerPad(picocolors_1.default.dim(`last session ${(0, config_store_1.formatSavedAt)(saved.savedAt)}`)));
|
|
79
|
+
}
|
|
80
|
+
console.log('\n' + renderConfigPanel(defaults, saved) + '\n');
|
|
81
|
+
const promptTheme = { prefix: picocolors_1.default.blue('◆') };
|
|
82
|
+
const choice = await (0, prompts_1.select)({
|
|
83
|
+
message: 'What would you like to do?',
|
|
84
|
+
theme: promptTheme,
|
|
85
|
+
choices: [
|
|
86
|
+
{ name: `${picocolors_1.default.green('▶')} Continue`, value: 'continue', short: 'Continue' },
|
|
87
|
+
{ name: `${picocolors_1.default.yellow('◈')} Change configuration (pick manually)`, value: 'change', short: 'Change configuration' },
|
|
88
|
+
{ name: `${picocolors_1.default.dim('✕')} Cancel (exit wizard)`, value: 'cancel', short: 'Cancel' },
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
if (choice === 'cancel') {
|
|
92
|
+
console.clear();
|
|
93
|
+
console.log(picocolors_1.default.dim('Setup cancelled. zero-mock aborted.'));
|
|
94
|
+
process.exit(0);
|
|
95
|
+
}
|
|
96
|
+
if (choice === 'continue') {
|
|
97
|
+
console.clear();
|
|
98
|
+
(0, config_store_1.saveConfig)(defaults, saved);
|
|
99
|
+
return defaults;
|
|
100
|
+
}
|
|
101
|
+
// Change configuration
|
|
102
|
+
console.clear();
|
|
103
|
+
console.log('\n' + sectionHeader('CUSTOM CONFIGURATION'));
|
|
104
|
+
if (isReturning) {
|
|
105
|
+
console.log(picocolors_1.default.dim('Pre-filled from last session — press Enter to keep\n'));
|
|
106
|
+
}
|
|
107
|
+
const file = await (0, prompts_1.input)({
|
|
108
|
+
message: 'Target File Path:',
|
|
109
|
+
default: defaults.file,
|
|
110
|
+
theme: promptTheme,
|
|
111
|
+
validate: (value) => {
|
|
112
|
+
if (!value.trim())
|
|
113
|
+
return "File path cannot be empty.";
|
|
114
|
+
if (!value.endsWith('.json'))
|
|
115
|
+
return "File must be a JSON file (.json).";
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
const port = await (0, prompts_1.input)({
|
|
120
|
+
message: 'Port Number:',
|
|
121
|
+
default: defaults.port,
|
|
122
|
+
theme: promptTheme,
|
|
123
|
+
validate: (value) => {
|
|
124
|
+
const p = parseInt(value, 10);
|
|
125
|
+
if (isNaN(p) || p < 1 || p > 65535)
|
|
126
|
+
return "Port must be a number between 1 and 65535.";
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
const delay = await (0, prompts_1.input)({
|
|
131
|
+
message: 'Simulated Latency (ms):',
|
|
132
|
+
default: defaults.delay,
|
|
133
|
+
theme: promptTheme,
|
|
134
|
+
validate: (value) => {
|
|
135
|
+
const d = parseInt(value, 10);
|
|
136
|
+
if (isNaN(d) || d < 0)
|
|
137
|
+
return "Latency must be a non-negative integer.";
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
const watch = await (0, prompts_1.confirm)({
|
|
142
|
+
message: 'Enable Watch Mode (auto-reload on save)?',
|
|
143
|
+
default: defaults.watch,
|
|
144
|
+
theme: promptTheme,
|
|
145
|
+
});
|
|
146
|
+
const newConfig = { file, port, delay, watch };
|
|
147
|
+
(0, config_store_1.saveConfig)(newConfig, saved);
|
|
148
|
+
console.log(`\n ${picocolors_1.default.green('✓')} Configuration updated.`);
|
|
149
|
+
console.clear();
|
|
150
|
+
return newConfig;
|
|
151
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
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.loadSavedConfig = loadSavedConfig;
|
|
7
|
+
exports.saveConfig = saveConfig;
|
|
8
|
+
exports.clearSavedConfig = clearSavedConfig;
|
|
9
|
+
exports.formatSavedAt = formatSavedAt;
|
|
10
|
+
const fs_1 = __importDefault(require("fs"));
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
const os_1 = __importDefault(require("os"));
|
|
13
|
+
const picocolors_1 = __importDefault(require("picocolors"));
|
|
14
|
+
const getConfigPath = () => path_1.default.join(os_1.default.homedir(), '.config', 'zero-mock', 'config.json');
|
|
15
|
+
function loadSavedConfig() {
|
|
16
|
+
try {
|
|
17
|
+
const configPath = getConfigPath();
|
|
18
|
+
if (!fs_1.default.existsSync(configPath)) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const raw = fs_1.default.readFileSync(configPath, 'utf8');
|
|
22
|
+
const parsed = JSON.parse(raw);
|
|
23
|
+
if (typeof parsed === 'object' &&
|
|
24
|
+
parsed !== null &&
|
|
25
|
+
typeof parsed.file === 'string' &&
|
|
26
|
+
typeof parsed.port === 'string' &&
|
|
27
|
+
typeof parsed.delay === 'string' &&
|
|
28
|
+
typeof parsed.watch === 'boolean') {
|
|
29
|
+
return parsed;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function saveConfig(config, prev) {
|
|
38
|
+
try {
|
|
39
|
+
const configPath = getConfigPath();
|
|
40
|
+
const configDir = path_1.default.dirname(configPath);
|
|
41
|
+
fs_1.default.mkdirSync(configDir, { recursive: true });
|
|
42
|
+
const savedConfig = {
|
|
43
|
+
...config,
|
|
44
|
+
savedAt: new Date().toISOString(),
|
|
45
|
+
useCount: Math.min((prev?.useCount || 0) + 1, 99999),
|
|
46
|
+
};
|
|
47
|
+
fs_1.default.writeFileSync(configPath, JSON.stringify(savedConfig, null, 2), 'utf8');
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Non-fatal, do nothing
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function clearSavedConfig() {
|
|
54
|
+
try {
|
|
55
|
+
const configPath = getConfigPath();
|
|
56
|
+
if (fs_1.default.existsSync(configPath)) {
|
|
57
|
+
fs_1.default.unlinkSync(configPath);
|
|
58
|
+
console.log(`${picocolors_1.default.green('✓')} Cleared saved configuration.`);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
console.log(`${picocolors_1.default.dim('✕')} No saved configuration found.`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
console.log(`${picocolors_1.default.red('✕')} Failed to clear config: ${err.message}`);
|
|
66
|
+
}
|
|
67
|
+
process.exit(0);
|
|
68
|
+
}
|
|
69
|
+
function formatSavedAt(iso) {
|
|
70
|
+
const date = new Date(iso);
|
|
71
|
+
const now = new Date();
|
|
72
|
+
const diffMs = now.getTime() - date.getTime();
|
|
73
|
+
const mins = Math.floor(diffMs / 60000);
|
|
74
|
+
if (mins < 2)
|
|
75
|
+
return "just now";
|
|
76
|
+
const hours = Math.floor(mins / 60);
|
|
77
|
+
if (hours < 1)
|
|
78
|
+
return `${mins}m ago`;
|
|
79
|
+
const days = Math.floor(hours / 24);
|
|
80
|
+
if (days < 1)
|
|
81
|
+
return `${hours}h ago`;
|
|
82
|
+
if (days < 7)
|
|
83
|
+
return `${days}d ago`;
|
|
84
|
+
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
85
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
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.printError = printError;
|
|
7
|
+
exports.printFatal = printFatal;
|
|
8
|
+
exports.validateConfig = validateConfig;
|
|
9
|
+
exports.acquireLock = acquireLock;
|
|
10
|
+
const picocolors_1 = __importDefault(require("picocolors"));
|
|
11
|
+
const fs_1 = __importDefault(require("fs"));
|
|
12
|
+
const path_1 = __importDefault(require("path"));
|
|
13
|
+
const os_1 = __importDefault(require("os"));
|
|
14
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
15
|
+
const cleanup_1 = require("./cleanup");
|
|
16
|
+
const ERROR_MESSAGES = {
|
|
17
|
+
FILE_NOT_FOUND: { msg: 'Target JSON file not found.', hint: 'Check if the file path is correct.' },
|
|
18
|
+
FILE_NOT_JSON: { msg: 'Target file is not a JSON file.', hint: 'Ensure the file ends with .json.' },
|
|
19
|
+
FILE_UNREADABLE: { msg: 'Cannot read the target file.', hint: 'Check file permissions.' },
|
|
20
|
+
JSON_INVALID: { msg: 'Invalid JSON format.', hint: 'Check the file for syntax errors.' },
|
|
21
|
+
JSON_NOT_OBJECT: { msg: 'JSON root is not an object.', hint: 'The JSON file must have an object at its root.' },
|
|
22
|
+
JSON_EMPTY: { msg: 'JSON file is empty.', hint: 'The API will start with no resources. Add arrays to the root object.' },
|
|
23
|
+
PORT_IN_USE: { msg: 'Port is already in use.', hint: 'Choose a different port or kill the process using it.' },
|
|
24
|
+
PORT_INVALID: { msg: 'Invalid port number.', hint: 'Port must be an integer between 1024 and 65535.' },
|
|
25
|
+
PORT_PRIVILEGED: { msg: 'Port requires root privileges.', hint: 'Use a port number >= 1024.' },
|
|
26
|
+
LOCK_STALE: { msg: 'Found a stale lock file.', hint: 'The lock file will be automatically removed.' },
|
|
27
|
+
LOCK_CONFLICT: { msg: 'Another instance is running.', hint: 'Stop the other zero-mock instance or use a different file.' },
|
|
28
|
+
LOCK_WRITE_FAILED: { msg: 'Failed to write lock file.', hint: 'Check directory permissions.' },
|
|
29
|
+
WATCH_FAILED: { msg: 'File watcher failed to start.', hint: 'Hot-reloading will not work. Check OS file watch limits.' },
|
|
30
|
+
WATCH_RELOAD_FAILED: { msg: 'Failed to reload JSON file.', hint: 'Fix the JSON syntax error. Serving last good snapshot.' },
|
|
31
|
+
SERVER_CRASH: { msg: 'Server crashed unexpectedly.', hint: 'Check the error details below.' },
|
|
32
|
+
UNKNOWN: { msg: 'An unknown error occurred.', hint: 'Please report this issue.' },
|
|
33
|
+
};
|
|
34
|
+
function formatErrorBlock(type, code, detail) {
|
|
35
|
+
const badge = type === 'ERROR'
|
|
36
|
+
? picocolors_1.default.red(picocolors_1.default.bold(picocolors_1.default.inverse(' ERROR ')))
|
|
37
|
+
: picocolors_1.default.yellow(picocolors_1.default.bold(picocolors_1.default.inverse(' WARN ')));
|
|
38
|
+
const { msg, hint } = ERROR_MESSAGES[code];
|
|
39
|
+
let out = `\n ${badge} ${picocolors_1.default.bold(picocolors_1.default.white(msg))}\n`;
|
|
40
|
+
if (detail) {
|
|
41
|
+
out += ` ${picocolors_1.default.dim('│')} ${picocolors_1.default.dim(detail)}\n`;
|
|
42
|
+
}
|
|
43
|
+
out += ` ${picocolors_1.default.dim('╰─')} ${picocolors_1.default.blue(hint)}\n`;
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
function printError(code, detail) {
|
|
47
|
+
console.error(formatErrorBlock('WARN', code, detail));
|
|
48
|
+
}
|
|
49
|
+
function printFatal(code, detail) {
|
|
50
|
+
console.error(formatErrorBlock('ERROR', code, detail));
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
function validateConfig(file, port) {
|
|
54
|
+
if (path_1.default.extname(file) !== '.json') {
|
|
55
|
+
printFatal('FILE_NOT_JSON', `Path: ${file}`);
|
|
56
|
+
}
|
|
57
|
+
if (!fs_1.default.existsSync(file)) {
|
|
58
|
+
printFatal('FILE_NOT_FOUND', `Path: ${file}`);
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
fs_1.default.accessSync(file, fs_1.default.constants.R_OK);
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
printFatal('FILE_UNREADABLE', e.message);
|
|
65
|
+
}
|
|
66
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
67
|
+
printFatal('PORT_INVALID', `Given: ${port}`);
|
|
68
|
+
}
|
|
69
|
+
if (port < 1024) {
|
|
70
|
+
printFatal('PORT_PRIVILEGED', `Given: ${port}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function acquireLock(file) {
|
|
74
|
+
const absoluteDbPath = path_1.default.resolve(file);
|
|
75
|
+
const hash = crypto_1.default.createHash('md5').update(absoluteDbPath).digest('hex');
|
|
76
|
+
const lockFilePath = path_1.default.join(os_1.default.tmpdir(), `zero-mock-${hash}.lock`);
|
|
77
|
+
if (fs_1.default.existsSync(lockFilePath)) {
|
|
78
|
+
try {
|
|
79
|
+
const lockData = fs_1.default.readFileSync(lockFilePath, 'utf8');
|
|
80
|
+
const { pid, startedAt } = JSON.parse(lockData);
|
|
81
|
+
try {
|
|
82
|
+
process.kill(pid, 0); // test liveness
|
|
83
|
+
// Alive
|
|
84
|
+
printFatal('LOCK_CONFLICT', `PID ${pid} since ${startedAt}`);
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
if (e.code === 'ESRCH') {
|
|
88
|
+
// Stale
|
|
89
|
+
printError('LOCK_STALE', `PID ${pid} is dead.`);
|
|
90
|
+
fs_1.default.unlinkSync(lockFilePath);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
// No permission or other error
|
|
94
|
+
printFatal('LOCK_CONFLICT', `Unable to check PID ${pid}.`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
// Unreadable or invalid lock file format
|
|
100
|
+
printError('LOCK_STALE', `Invalid lock file format.`);
|
|
101
|
+
fs_1.default.unlinkSync(lockFilePath);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
fs_1.default.writeFileSync(lockFilePath, JSON.stringify({
|
|
106
|
+
pid: process.pid,
|
|
107
|
+
startedAt: new Date().toISOString()
|
|
108
|
+
}), { flag: 'wx' });
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
if (e.code === 'EEXIST') {
|
|
112
|
+
printFatal('LOCK_CONFLICT', 'Lock file appeared during race condition.');
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
printFatal('LOCK_WRITE_FAILED', e.message);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
(0, cleanup_1.registerLock)(lockFilePath);
|
|
119
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,13 +1,50 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
4
|
+
if (k2 === undefined) k2 = k;
|
|
5
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
6
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
7
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
8
|
+
}
|
|
9
|
+
Object.defineProperty(o, k2, desc);
|
|
10
|
+
}) : (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
o[k2] = m[k];
|
|
13
|
+
}));
|
|
14
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
15
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
16
|
+
}) : function(o, v) {
|
|
17
|
+
o["default"] = v;
|
|
18
|
+
});
|
|
19
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
20
|
+
var ownKeys = function(o) {
|
|
21
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
22
|
+
var ar = [];
|
|
23
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
24
|
+
return ar;
|
|
25
|
+
};
|
|
26
|
+
return ownKeys(o);
|
|
27
|
+
};
|
|
28
|
+
return function (mod) {
|
|
29
|
+
if (mod && mod.__esModule) return mod;
|
|
30
|
+
var result = {};
|
|
31
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
32
|
+
__setModuleDefault(result, mod);
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
})();
|
|
3
36
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
37
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
38
|
};
|
|
6
39
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
-
const
|
|
40
|
+
const chokidar = __importStar(require("chokidar"));
|
|
41
|
+
const picocolors_1 = __importDefault(require("picocolors"));
|
|
8
42
|
const commander_1 = require("commander");
|
|
9
43
|
const jsonStore_1 = require("./store/jsonStore");
|
|
10
44
|
const bootstrap_1 = require("./server/bootstrap");
|
|
45
|
+
const cli_wizard_1 = require("./cli-wizard");
|
|
46
|
+
const errors_1 = require("./errors");
|
|
47
|
+
const config_store_1 = require("./config-store");
|
|
11
48
|
function parseDelayMs(raw) {
|
|
12
49
|
const trimmed = raw.trim();
|
|
13
50
|
if (!/^\d+$/.test(trimmed)) {
|
|
@@ -15,67 +52,95 @@ function parseDelayMs(raw) {
|
|
|
15
52
|
}
|
|
16
53
|
return Number.parseInt(trimmed, 10);
|
|
17
54
|
}
|
|
55
|
+
const program = new commander_1.Command();
|
|
56
|
+
let watcher = null;
|
|
18
57
|
function startFileWatcher(filePath) {
|
|
19
58
|
let reloadTimeout = null;
|
|
59
|
+
if (watcher) {
|
|
60
|
+
watcher.close().catch(() => { });
|
|
61
|
+
watcher = null;
|
|
62
|
+
}
|
|
20
63
|
const reload = async () => {
|
|
21
64
|
try {
|
|
22
|
-
await jsonStore_1.JsonStore.load(filePath);
|
|
23
|
-
console.log(
|
|
65
|
+
await jsonStore_1.JsonStore.load(filePath, true);
|
|
66
|
+
console.log(`\n ${picocolors_1.default.dim('│')} ${picocolors_1.default.green('✓')} ${picocolors_1.default.dim('Hot reloaded data from')} ${picocolors_1.default.white(filePath)}`);
|
|
24
67
|
}
|
|
25
68
|
catch (err) {
|
|
26
|
-
|
|
27
|
-
console.error(`[watch] Could not reload "${filePath}": ${message}`);
|
|
69
|
+
(0, errors_1.printError)('WATCH_RELOAD_FAILED', err.message);
|
|
28
70
|
}
|
|
29
71
|
};
|
|
30
|
-
|
|
31
|
-
.watch(filePath, { persistent: true, ignoreInitial: true })
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
72
|
+
try {
|
|
73
|
+
watcher = chokidar.watch(filePath, { persistent: true, ignoreInitial: true });
|
|
74
|
+
watcher
|
|
75
|
+
.on("change", () => {
|
|
76
|
+
if (reloadTimeout)
|
|
77
|
+
clearTimeout(reloadTimeout);
|
|
78
|
+
reloadTimeout = setTimeout(() => void reload(), 100);
|
|
79
|
+
})
|
|
80
|
+
.on("error", (err) => {
|
|
81
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
82
|
+
(0, errors_1.printError)('WATCH_FAILED', msg);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
(0, errors_1.printError)('WATCH_FAILED', err.message);
|
|
87
|
+
}
|
|
42
88
|
}
|
|
43
|
-
const program = new commander_1.Command();
|
|
44
89
|
program
|
|
45
90
|
.name("zero-mock")
|
|
46
91
|
.description("Generate a REST API from a JSON file")
|
|
47
|
-
.
|
|
48
|
-
.option("-p, --port <number>", "HTTP port"
|
|
49
|
-
.option("-d, --delay <ms>", "delay each request by this many ms (0 = off)"
|
|
50
|
-
.option("-w, --watch", "reload JSON from disk when the file changes"
|
|
92
|
+
.option("-f, --file <path>", "path to the source JSON file")
|
|
93
|
+
.option("-p, --port <number>", "HTTP port")
|
|
94
|
+
.option("-d, --delay <ms>", "delay each request by this many ms (0 = off)")
|
|
95
|
+
.option("-w, --watch", "reload JSON from disk when the file changes")
|
|
51
96
|
.option("--cors-origin <origins>", "comma-separated list of allowed origins (e.g., http://localhost:3000)", "*")
|
|
52
97
|
.option("--cors-methods <methods>", "comma-separated list of allowed HTTP methods", "GET,HEAD,PUT,PATCH,POST,DELETE")
|
|
53
98
|
.option("--cors-credentials", "enable CORS credentials (cookies, authorization headers)", false)
|
|
99
|
+
.option("--reset", "clear saved wizard configuration")
|
|
54
100
|
.action(async (opts) => {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
101
|
+
if (process.argv.includes('--reset')) {
|
|
102
|
+
(0, config_store_1.clearSavedConfig)();
|
|
103
|
+
process.exit(0);
|
|
104
|
+
}
|
|
105
|
+
let { file, port: portRaw, delay: delayRaw, watch } = opts;
|
|
106
|
+
if (!file && !portRaw && !delayRaw && watch === undefined) {
|
|
107
|
+
const wizardConfig = await (0, cli_wizard_1.runWizard)();
|
|
108
|
+
file = wizardConfig.file;
|
|
109
|
+
portRaw = wizardConfig.port;
|
|
110
|
+
delayRaw = wizardConfig.delay;
|
|
111
|
+
watch = wizardConfig.watch;
|
|
60
112
|
}
|
|
61
|
-
|
|
113
|
+
else {
|
|
114
|
+
if (!file) {
|
|
115
|
+
console.error("error: required option '-f, --file <path>' not specified");
|
|
116
|
+
process.exit(1);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
portRaw = portRaw ?? "3000";
|
|
120
|
+
delayRaw = delayRaw ?? "0";
|
|
121
|
+
watch = watch ?? false;
|
|
122
|
+
}
|
|
123
|
+
const port = Number.parseInt(portRaw, 10);
|
|
124
|
+
const delayMs = parseDelayMs(delayRaw);
|
|
62
125
|
if (delayMs === null) {
|
|
63
126
|
console.error("error: --delay must be a non-negative integer");
|
|
64
127
|
process.exit(1);
|
|
65
128
|
return;
|
|
66
129
|
}
|
|
67
|
-
const filePath =
|
|
130
|
+
const filePath = file;
|
|
131
|
+
// Ordered Validation Step 1 & 2
|
|
132
|
+
(0, errors_1.validateConfig)(filePath, port);
|
|
133
|
+
(0, errors_1.acquireLock)(filePath);
|
|
68
134
|
try {
|
|
69
135
|
await jsonStore_1.JsonStore.load(filePath);
|
|
70
136
|
}
|
|
71
137
|
catch (err) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
return;
|
|
138
|
+
// JsonStore.load handles its own printFatal internally,
|
|
139
|
+
// but we catch to ensure we don't crash without formatting.
|
|
140
|
+
(0, errors_1.printFatal)('UNKNOWN', err.message);
|
|
76
141
|
}
|
|
77
142
|
try {
|
|
78
|
-
await (0, bootstrap_1.bootstrap)(port, {
|
|
143
|
+
await (0, bootstrap_1.bootstrap)(port, filePath, watch, {
|
|
79
144
|
delayMs,
|
|
80
145
|
corsOrigin: opts.corsOrigin,
|
|
81
146
|
corsMethods: opts.corsMethods,
|
|
@@ -83,13 +148,18 @@ program
|
|
|
83
148
|
});
|
|
84
149
|
}
|
|
85
150
|
catch (err) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
process.exit(1);
|
|
89
|
-
return;
|
|
151
|
+
// Bootstrap will handle EADDRINUSE internally, catching just in case.
|
|
152
|
+
(0, errors_1.printFatal)('UNKNOWN', err.message);
|
|
90
153
|
}
|
|
91
|
-
if (
|
|
154
|
+
if (watch) {
|
|
92
155
|
startFileWatcher(filePath);
|
|
93
156
|
}
|
|
157
|
+
const cleanup = () => {
|
|
158
|
+
if (watcher) {
|
|
159
|
+
watcher.close().catch(() => { });
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
process.on('SIGINT', cleanup);
|
|
163
|
+
process.on('SIGTERM', cleanup);
|
|
94
164
|
});
|
|
95
165
|
program.parse(process.argv);
|
package/dist/server/app.js
CHANGED
|
@@ -7,6 +7,7 @@ exports.createApp = createApp;
|
|
|
7
7
|
const cors_1 = __importDefault(require("cors"));
|
|
8
8
|
const express_1 = __importDefault(require("express"));
|
|
9
9
|
const dynamicRouter_1 = require("./routes/dynamicRouter");
|
|
10
|
+
const logger_1 = require("./logger");
|
|
10
11
|
const errorHandler = (err, _req, res, _next) => {
|
|
11
12
|
if (res.headersSent) {
|
|
12
13
|
return;
|
|
@@ -14,12 +15,6 @@ const errorHandler = (err, _req, res, _next) => {
|
|
|
14
15
|
const message = err instanceof Error ? err.message : String(err);
|
|
15
16
|
res.status(500).json({ error: "INTERNAL_SERVER_ERROR", message, statusCode: 500 });
|
|
16
17
|
};
|
|
17
|
-
function requestLoggingMiddleware(req, res, next) {
|
|
18
|
-
res.on("finish", () => {
|
|
19
|
-
console.log(`[${req.method}] ${req.path} - ${res.statusCode}`);
|
|
20
|
-
});
|
|
21
|
-
next();
|
|
22
|
-
}
|
|
23
18
|
function delayMiddleware(delayMs) {
|
|
24
19
|
return (_req, _res, next) => {
|
|
25
20
|
if (delayMs <= 0) {
|
|
@@ -37,7 +32,7 @@ function createApp(options) {
|
|
|
37
32
|
credentials: options.corsCredentials || false,
|
|
38
33
|
}));
|
|
39
34
|
app.use(express_1.default.json());
|
|
40
|
-
app.use(requestLoggingMiddleware);
|
|
35
|
+
app.use(logger_1.requestLoggingMiddleware);
|
|
41
36
|
app.use(delayMiddleware(options.delayMs));
|
|
42
37
|
app.use("/", (0, dynamicRouter_1.buildDynamicRouter)());
|
|
43
38
|
app.use(errorHandler);
|
package/dist/server/bootstrap.js
CHANGED
|
@@ -3,20 +3,24 @@ 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
|
-
|
|
6
|
+
const logger_1 = require("./logger");
|
|
7
|
+
const errors_1 = require("../errors");
|
|
8
|
+
async function bootstrap(port, file, watch, appOptions) {
|
|
7
9
|
const app = (0, app_1.createApp)(appOptions);
|
|
8
10
|
await new Promise((resolve, reject) => {
|
|
9
11
|
const server = app.listen(port, () => {
|
|
10
12
|
resolve();
|
|
11
13
|
});
|
|
12
|
-
server.on("error",
|
|
14
|
+
server.on("error", (e) => {
|
|
15
|
+
if (e.code === 'EADDRINUSE') {
|
|
16
|
+
(0, errors_1.printFatal)('PORT_IN_USE', `Port ${port} — ${e.message}`);
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
(0, errors_1.printFatal)('UNKNOWN', e.message);
|
|
20
|
+
}
|
|
21
|
+
reject(e);
|
|
22
|
+
});
|
|
13
23
|
});
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
console.log("Read/write API: GET, POST, PUT, PATCH, DELETE are active for each resource below.\n");
|
|
17
|
-
for (const resource of Object.keys(jsonStore_1.JsonStore.getData())) {
|
|
18
|
-
console.log(` ${resource}`);
|
|
19
|
-
console.log(` GET POST ${base}/${resource}`);
|
|
20
|
-
console.log(` GET PUT PATCH DELETE ${base}/${resource}/:id`);
|
|
21
|
-
}
|
|
24
|
+
const resources = Object.keys(jsonStore_1.JsonStore.getData());
|
|
25
|
+
(0, logger_1.printStartupBanner)(file, port, watch, appOptions.delayMs, resources);
|
|
22
26
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
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.getMethodBadge = getMethodBadge;
|
|
7
|
+
exports.requestLoggingMiddleware = requestLoggingMiddleware;
|
|
8
|
+
exports.getMonochromeBadge = getMonochromeBadge;
|
|
9
|
+
exports.printStartupBanner = printStartupBanner;
|
|
10
|
+
const picocolors_1 = __importDefault(require("picocolors"));
|
|
11
|
+
const cli_wizard_1 = require("../cli-wizard");
|
|
12
|
+
function getMethodBadge(method) {
|
|
13
|
+
const m = method.toUpperCase();
|
|
14
|
+
const padded = m.padEnd(6, ' ');
|
|
15
|
+
const inner = `[${padded}]`;
|
|
16
|
+
switch (m) {
|
|
17
|
+
case 'GET': return picocolors_1.default.bold(picocolors_1.default.cyan(inner));
|
|
18
|
+
case 'POST': return picocolors_1.default.bold(picocolors_1.default.green(inner));
|
|
19
|
+
case 'PUT': return picocolors_1.default.bold(picocolors_1.default.yellow(inner));
|
|
20
|
+
case 'PATCH': return picocolors_1.default.bold(picocolors_1.default.magenta(inner));
|
|
21
|
+
case 'DELETE': return picocolors_1.default.bold(picocolors_1.default.red(inner));
|
|
22
|
+
default: return picocolors_1.default.bold(picocolors_1.default.white(inner));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function requestLoggingMiddleware(req, res, next) {
|
|
26
|
+
const startMs = Date.now();
|
|
27
|
+
const start = new Date();
|
|
28
|
+
res.on("finish", () => {
|
|
29
|
+
const timeStr = start.toLocaleTimeString('en-US', { hour12: true });
|
|
30
|
+
const duration = Date.now() - startMs;
|
|
31
|
+
const badge = getMethodBadge(req.method);
|
|
32
|
+
let statusStr = String(res.statusCode);
|
|
33
|
+
if (res.statusCode >= 200 && res.statusCode < 300)
|
|
34
|
+
statusStr = picocolors_1.default.green(statusStr);
|
|
35
|
+
else if (res.statusCode >= 300 && res.statusCode < 400)
|
|
36
|
+
statusStr = picocolors_1.default.cyan(statusStr);
|
|
37
|
+
else if (res.statusCode >= 400 && res.statusCode < 500)
|
|
38
|
+
statusStr = picocolors_1.default.yellow(statusStr);
|
|
39
|
+
else if (res.statusCode >= 500)
|
|
40
|
+
statusStr = picocolors_1.default.red(statusStr);
|
|
41
|
+
const pathPadded = req.path.length > 20 ? req.path : req.path.padEnd(20, ' ');
|
|
42
|
+
console.log(`${picocolors_1.default.dim(`[${timeStr}]`)} ${badge} ${picocolors_1.default.white(pathPadded)} ${statusStr} ${picocolors_1.default.dim(`${duration}ms`)}`);
|
|
43
|
+
});
|
|
44
|
+
next();
|
|
45
|
+
}
|
|
46
|
+
function getMonochromeBadge(method) {
|
|
47
|
+
const m = method.toUpperCase();
|
|
48
|
+
const padded = m.padEnd(6, ' ');
|
|
49
|
+
return `[${padded}]`;
|
|
50
|
+
}
|
|
51
|
+
function printStartupBanner(file, port, isWatch, delayMs, resources) {
|
|
52
|
+
console.log('\n' + (0, cli_wizard_1.sectionHeader)('SERVER READY'));
|
|
53
|
+
console.log((0, cli_wizard_1.renderConfigPanel)({
|
|
54
|
+
file,
|
|
55
|
+
port: String(port),
|
|
56
|
+
delay: String(delayMs),
|
|
57
|
+
watch: isWatch
|
|
58
|
+
}) + '\n');
|
|
59
|
+
console.log((0, cli_wizard_1.sectionHeader)('AVAILABLE RESOURCES'));
|
|
60
|
+
const base = `http://localhost:${port}`;
|
|
61
|
+
const get = getMonochromeBadge('GET');
|
|
62
|
+
const post = getMonochromeBadge('POST');
|
|
63
|
+
const put = getMonochromeBadge('PUT');
|
|
64
|
+
const patch = getMonochromeBadge('PATCH');
|
|
65
|
+
const del = getMonochromeBadge('DELETE');
|
|
66
|
+
for (const resource of resources) {
|
|
67
|
+
console.log(picocolors_1.default.bold(picocolors_1.default.white(`■ /${resource}`)));
|
|
68
|
+
const methodsLeft1 = ` ${get} ${post}`;
|
|
69
|
+
const methodsLeft2 = ` ${get} ${put} ${patch} ${del}`;
|
|
70
|
+
// Align URLs at column 45
|
|
71
|
+
const pad1 = ' '.repeat(Math.max(1, 45 - (0, cli_wizard_1.stripAnsi)(methodsLeft1).length));
|
|
72
|
+
const pad2 = ' '.repeat(Math.max(1, 45 - (0, cli_wizard_1.stripAnsi)(methodsLeft2).length));
|
|
73
|
+
console.log(`${methodsLeft1}${pad1}${picocolors_1.default.white(`${base}/${resource}`)}`);
|
|
74
|
+
console.log(`${methodsLeft2}${pad2}${picocolors_1.default.white(`${base}/${resource}/:id`)}\n`);
|
|
75
|
+
}
|
|
76
|
+
console.log((0, cli_wizard_1.sectionHeader)('REQUEST LOGS'));
|
|
77
|
+
console.log(`${picocolors_1.default.green('✓')} ${picocolors_1.default.dim('Ready on')} ${picocolors_1.default.white(`${base}`)}\n`);
|
|
78
|
+
}
|
|
@@ -109,7 +109,8 @@ function filterCollection(items, query) {
|
|
|
109
109
|
else if (op === "like") {
|
|
110
110
|
if (actual === undefined || actual === null)
|
|
111
111
|
return false;
|
|
112
|
-
|
|
112
|
+
const escaped = want.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
113
|
+
if (!new RegExp(escaped, 'i').test(String(actual)))
|
|
113
114
|
return false;
|
|
114
115
|
}
|
|
115
116
|
}
|
|
@@ -235,10 +236,23 @@ function buildDynamicRouter() {
|
|
|
235
236
|
const collection = jsonStore_1.JsonStore.getData()[resource];
|
|
236
237
|
const query = req.query;
|
|
237
238
|
let rows = filterCollection(collection, query);
|
|
239
|
+
const total = rows.length;
|
|
240
|
+
res.setHeader("X-Total-Count", total);
|
|
238
241
|
const pageInfo = parsePagination(query);
|
|
239
242
|
if (pageInfo !== null) {
|
|
240
243
|
const start = (pageInfo.page - 1) * pageInfo.limit;
|
|
241
244
|
rows = rows.slice(start, start + pageInfo.limit);
|
|
245
|
+
const totalPages = Math.ceil(total / pageInfo.limit);
|
|
246
|
+
res.json({
|
|
247
|
+
data: rows,
|
|
248
|
+
pagination: {
|
|
249
|
+
total,
|
|
250
|
+
page: pageInfo.page,
|
|
251
|
+
limit: pageInfo.limit,
|
|
252
|
+
totalPages
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
return;
|
|
242
256
|
}
|
|
243
257
|
res.json(rows);
|
|
244
258
|
});
|
package/dist/store/jsonStore.js
CHANGED
|
@@ -1,78 +1,63 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.JsonStore = void 0;
|
|
4
7
|
const crypto_1 = require("crypto");
|
|
5
8
|
const path_1 = require("path");
|
|
9
|
+
const fs_1 = __importDefault(require("fs"));
|
|
6
10
|
const promises_1 = require("fs/promises");
|
|
11
|
+
const errors_1 = require("../errors");
|
|
7
12
|
function isPlainObject(value) {
|
|
8
13
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
14
|
}
|
|
10
|
-
function parseJsonStorePayload(raw, filePath) {
|
|
11
|
-
let parsed;
|
|
12
|
-
try {
|
|
13
|
-
parsed = JSON.parse(raw);
|
|
14
|
-
}
|
|
15
|
-
catch (err) {
|
|
16
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
17
|
-
throw new Error(`Invalid JSON in "${filePath}": ${message}`);
|
|
18
|
-
}
|
|
19
|
-
if (!isPlainObject(parsed)) {
|
|
20
|
-
throw new Error(`JSON root in "${filePath}" must be a non-null object (got ${parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed}).`);
|
|
21
|
-
}
|
|
22
|
-
const data = {};
|
|
23
|
-
for (const [key, value] of Object.entries(parsed)) {
|
|
24
|
-
if (!Array.isArray(value)) {
|
|
25
|
-
throw new Error(`Invalid mock database shape in "${filePath}": property "${key}" must be an array.`);
|
|
26
|
-
}
|
|
27
|
-
data[key] = value;
|
|
28
|
-
}
|
|
29
|
-
return data;
|
|
30
|
-
}
|
|
31
15
|
class JsonStoreImpl {
|
|
32
16
|
data = {};
|
|
33
17
|
backingPath = null;
|
|
34
|
-
lockPath = null;
|
|
35
18
|
/** Serializes concurrent save() calls so writes are not interleaved. */
|
|
36
19
|
saveChain = Promise.resolve();
|
|
37
|
-
async load(filePath) {
|
|
38
|
-
|
|
20
|
+
async load(filePath, isReload = false) {
|
|
21
|
+
let raw;
|
|
39
22
|
try {
|
|
40
|
-
await (0, promises_1.
|
|
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.`);
|
|
23
|
+
raw = await (0, promises_1.readFile)(filePath, "utf8");
|
|
44
24
|
}
|
|
45
|
-
catch {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
25
|
+
catch (err) {
|
|
26
|
+
if (isReload)
|
|
27
|
+
throw new Error(`Unreadable: ${err.message}`);
|
|
28
|
+
(0, errors_1.printFatal)('FILE_UNREADABLE', err.message);
|
|
49
29
|
}
|
|
50
|
-
let
|
|
30
|
+
let parsed;
|
|
51
31
|
try {
|
|
52
|
-
|
|
32
|
+
parsed = JSON.parse(raw);
|
|
53
33
|
}
|
|
54
34
|
catch (err) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
? `Could not read JSON file "${filePath}" (${code}): ${message}`
|
|
59
|
-
: `Could not read JSON file "${filePath}": ${message}`);
|
|
35
|
+
if (isReload)
|
|
36
|
+
throw new Error(`Parse error: ${err.message}`);
|
|
37
|
+
(0, errors_1.printFatal)('JSON_INVALID', err.message);
|
|
60
38
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
39
|
+
if (!isPlainObject(parsed)) {
|
|
40
|
+
const msg = `got ${parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed}`;
|
|
41
|
+
if (isReload)
|
|
42
|
+
throw new Error(`Root not an object (${msg})`);
|
|
43
|
+
(0, errors_1.printFatal)('JSON_NOT_OBJECT', msg);
|
|
44
|
+
}
|
|
45
|
+
const keys = Object.keys(parsed);
|
|
46
|
+
if (keys.length === 0) {
|
|
47
|
+
(0, errors_1.printError)('JSON_EMPTY');
|
|
48
|
+
}
|
|
49
|
+
const data = {};
|
|
50
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
51
|
+
if (!Array.isArray(value)) {
|
|
52
|
+
const msg = `property "${key}" must be an array.`;
|
|
53
|
+
if (isReload)
|
|
54
|
+
throw new Error(msg);
|
|
55
|
+
(0, errors_1.printFatal)('JSON_INVALID', msg);
|
|
71
56
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
57
|
+
data[key] = value;
|
|
58
|
+
}
|
|
59
|
+
this.data = data;
|
|
60
|
+
this.backingPath = filePath;
|
|
76
61
|
}
|
|
77
62
|
getData() {
|
|
78
63
|
return this.data;
|
|
@@ -88,14 +73,26 @@ class JsonStoreImpl {
|
|
|
88
73
|
const run = async () => {
|
|
89
74
|
const target = this.backingPath;
|
|
90
75
|
const dir = (0, path_1.dirname)(target);
|
|
91
|
-
const tmp = (0, path_1.join)(dir, `.zero-mock-${(0, crypto_1.randomBytes)(8).toString("hex")}.
|
|
76
|
+
const tmp = (0, path_1.join)(dir, `.zero-mock-tmp-${(0, crypto_1.randomBytes)(8).toString("hex")}.json`);
|
|
92
77
|
const payload = `${JSON.stringify(this.data, null, 2)}\n`;
|
|
93
78
|
try {
|
|
94
79
|
await (0, promises_1.writeFile)(tmp, payload, "utf8");
|
|
95
|
-
|
|
80
|
+
try {
|
|
81
|
+
fs_1.default.renameSync(tmp, target);
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
if (e.code === 'EXDEV') {
|
|
85
|
+
fs_1.default.copyFileSync(tmp, target);
|
|
86
|
+
fs_1.default.unlinkSync(tmp);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
throw e;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
96
92
|
}
|
|
97
93
|
catch (err) {
|
|
98
|
-
|
|
94
|
+
if (fs_1.default.existsSync(tmp))
|
|
95
|
+
fs_1.default.unlinkSync(tmp);
|
|
99
96
|
const message = err instanceof Error ? err.message : String(err);
|
|
100
97
|
throw new Error(`Could not write JSON file "${target}": ${message}`);
|
|
101
98
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xirconsss/zero-mock",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Zero-config CLI that generates REST APIs from JSON files",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -47,10 +47,13 @@
|
|
|
47
47
|
},
|
|
48
48
|
"homepage": "https://github.com/xircons/zero-mock#readme",
|
|
49
49
|
"dependencies": {
|
|
50
|
+
"@inquirer/prompts": "^8.5.2",
|
|
50
51
|
"chokidar": "^4.0.3",
|
|
51
52
|
"commander": "^13.1.0",
|
|
52
53
|
"cors": "^2.8.5",
|
|
53
54
|
"express": "^4.21.2",
|
|
55
|
+
"gradient-string": "^3.0.0",
|
|
56
|
+
"picocolors": "^1.1.1",
|
|
54
57
|
"zod": "^4.4.3"
|
|
55
58
|
},
|
|
56
59
|
"devDependencies": {
|
|
@@ -64,6 +67,7 @@
|
|
|
64
67
|
"@semantic-release/release-notes-generator": "^14.1.1",
|
|
65
68
|
"@types/cors": "^2.8.17",
|
|
66
69
|
"@types/express": "^4.17.21",
|
|
70
|
+
"@types/gradient-string": "^1.1.6",
|
|
67
71
|
"@types/node": "^22.14.0",
|
|
68
72
|
"@vitest/coverage-v8": "^2.1.9",
|
|
69
73
|
"husky": "^9.1.7",
|
package/example/db.json
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"users": [
|
|
3
|
-
{
|
|
4
|
-
"id": 1,
|
|
5
|
-
"name": "Ada Lovelace",
|
|
6
|
-
"email": "ada@example.com",
|
|
7
|
-
"role": "admin"
|
|
8
|
-
},
|
|
9
|
-
{
|
|
10
|
-
"id": 2,
|
|
11
|
-
"name": "Grace Hopper",
|
|
12
|
-
"email": "grace@example.com",
|
|
13
|
-
"role": "editor"
|
|
14
|
-
},
|
|
15
|
-
{
|
|
16
|
-
"id": 3,
|
|
17
|
-
"name": "Margaret Hamilton",
|
|
18
|
-
"email": "margaret@example.com",
|
|
19
|
-
"role": "viewer"
|
|
20
|
-
}
|
|
21
|
-
],
|
|
22
|
-
"posts": [
|
|
23
|
-
{
|
|
24
|
-
"id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
|
|
25
|
-
"title": "Shipping a mock API in minutes",
|
|
26
|
-
"body": "Point zero-mock at a JSON file and iterate on your UI without waiting on backend tickets.",
|
|
27
|
-
"userId": 1,
|
|
28
|
-
"published": true
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
"id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e",
|
|
32
|
-
"title": "Why atomic writes matter",
|
|
33
|
-
"body": "Your JSON file stays the source of truth—even when multiple tabs hit the API at once.",
|
|
34
|
-
"userId": 2,
|
|
35
|
-
"published": false
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"id": "c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f",
|
|
39
|
-
"title": "Frontend-first workflows",
|
|
40
|
-
"body": "Prototype with realistic CRUD, then swap the mock for a production API when you are ready.",
|
|
41
|
-
"userId": 1,
|
|
42
|
-
"published": true
|
|
43
|
-
}
|
|
44
|
-
]
|
|
45
|
-
}
|