Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ node_modules/

# Build output
dist/
.turbo/

# Logs
logs/
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Built for frontend developers and architects who need a realistic, stable backen
- **Atomic and secure** — cross-platform atomic writes (Windows-safe) with file-locking to prevent data corruption.
- **Advanced REST** — supports filtering (`_gte`, `_lte`, `_like`), sorting (`_sort`, `_order`), and pagination (`_page`, `_limit`).
- **Realistic simulation** — simulate network latency and configure custom CORS origins/methods.
- **Watch mode** — hot-reloads data from disk on manual file changes without a server restart.
- **Watch mode** — hot-reloads data from disk on manual file changes without a server restart, including newly added or removed collections.
- **Session memory** — remembers your last used configuration for a faster workflow.

---
Expand Down Expand Up @@ -64,6 +64,8 @@ zero-mock -f ./data.json -p 8080 -w -d 200
| `-d`, `--delay` | Delay every request by X milliseconds (default `0`). |
| `-w`, `--watch` | Enable hot-reloading on manual file changes. |
| `--cors-origin` | Comma-separated allowed origins (default `*`). |
| `--cors-methods` | Comma-separated allowed HTTP methods (default `GET,HEAD,PUT,PATCH,POST,DELETE`). |
| `--cors-credentials` | Allow CORS credentials (cookies, auth headers). |
| `--reset` | Clear saved wizard configuration and exit. |

---
Expand All @@ -81,6 +83,8 @@ The tool generates full CRUD endpoints for every top-level key in your JSON (e.g
| `PATCH` | `/{resource}/{id}` | Partial update (schema validation). |
| `DELETE` | `/{resource}/{id}` | Remove item. |

Unknown routes and missing items return a JSON `404` (e.g. `{ "error": "Not found: GET /widgets" }`).

### Advanced List Features

**Filtering**
Expand Down
29 changes: 4 additions & 25 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
"dev:web": "turbo run dev --filter=web",
"dev:cli": "cd packages/cli && npm run dev",
"lint": "turbo run lint",
"test": "turbo run test"
"test": "turbo run test",
"test:coverage": "turbo run test:coverage"
},
"devDependencies": {
"turbo": "^2.9.18"
}
}
87 changes: 87 additions & 0 deletions packages/cli/src/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { Server } from 'http';
import type { AddressInfo } from 'net';
import fs from 'fs';
import path from 'path';
import { JsonStore } from '../store/jsonStore';
import { createApp } from '../server/app';

// Exercises the real Express stack (param routes, JSON 404, store wiring) over HTTP,
// rather than poking router internals.
describe('Dynamic router integration (real HTTP)', () => {
const tempFile = path.resolve(__dirname, 'temp-integration-db.json');
let server: Server;
let baseUrl: string;

beforeAll(async () => {
fs.writeFileSync(
tempFile,
JSON.stringify({ posts: [{ id: 1, title: 'Hello' }] }),
);
await JsonStore.load(tempFile);

const app = createApp({ delayMs: 0 });
await new Promise<void>((resolve) => {
server = app.listen(0, resolve);
});
const { port } = server.address() as AddressInfo;
baseUrl = `http://127.0.0.1:${port}`;
});

afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
});

it('lists a collection', async () => {
const res = await fetch(`${baseUrl}/posts`);
expect(res.status).toBe(200);
expect(await res.json()).toEqual([{ id: 1, title: 'Hello' }]);
});

it('gets an item by id', async () => {
const res = await fetch(`${baseUrl}/posts/1`);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ id: 1, title: 'Hello' });
});

it('returns JSON 404 for an unknown resource', async () => {
const res = await fetch(`${baseUrl}/widgets`);
expect(res.status).toBe(404);
expect(res.headers.get('content-type')).toContain('application/json');
const body = (await res.json()) as { error: string };
expect(body.error).toContain('Not found');
});

it('returns JSON 404 for a known resource with a missing id', async () => {
const res = await fetch(`${baseUrl}/posts/999`);
expect(res.status).toBe(404);
expect(((await res.json()) as { error: string }).error).toContain('999');
});

it('creates a record via POST and assigns the next id', async () => {
const res = await fetch(`${baseUrl}/posts`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: 'World' }),
});
expect(res.status).toBe(201);
expect(await res.json()).toEqual({ id: 2, title: 'World' });
});

it('serves a collection added to the store after startup (no restart)', async () => {
// Simulate a watch reload introducing a new top-level key.
fs.writeFileSync(
tempFile,
JSON.stringify({
posts: [{ id: 1, title: 'Hello' }],
comments: [{ id: 1, body: 'Nice' }],
}),
);
await JsonStore.load(tempFile, true);

const res = await fetch(`${baseUrl}/comments`);
expect(res.status).toBe(200);
expect(await res.json()).toEqual([{ id: 1, body: 'Nice' }]);
});
});
29 changes: 29 additions & 0 deletions packages/cli/src/__tests__/selfWrite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import path from 'path';
import { JsonStore } from '../store/jsonStore';

// Isolated in its own file so the JsonStore singleton starts with a clean
// (unset) self-write window.
describe('Self-write suppression', () => {
const tempFile = path.resolve(__dirname, 'temp-selfwrite-db.json');

beforeAll(() => {
fs.writeFileSync(tempFile, JSON.stringify({ posts: [{ id: 1 }] }));
});

afterAll(() => {
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
});

it('marks recent saves as self-caused so the watcher ignores them', async () => {
await JsonStore.load(tempFile);
expect(JsonStore.isSelfWrite()).toBe(false);

JsonStore.getData().posts.push({ id: 2 });
await JsonStore.save();

// Immediately after save(), a file event would be ours — must be suppressed.
expect(JsonStore.isSelfWrite()).toBe(true);
});
});
132 changes: 132 additions & 0 deletions packages/cli/src/__tests__/watch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import path from 'path';
import { JsonStore } from '../store/jsonStore';
import { buildDynamicRouter } from '../server/routes/dynamicRouter';

// The tests interrogate Express router internals, which aren't fully typed.
function getResourceGetHandler(router: any): (req: any, res: any, next: any) => Promise<void> {
const layer = router.stack.find(
(l: any) => l.route && l.route.path === '/:resource' && l.route.methods.get,
);
expect(layer).toBeDefined();
return layer.route.stack[0].handle;
}

describe('Watch Mode and Dynamic Routing', () => {
const tempFile = path.resolve(__dirname, 'temp-watch-db.json');

beforeAll(() => {
fs.writeFileSync(tempFile, JSON.stringify({
posts: [{ id: 1, title: 'Original Title' }]
}));
});

afterAll(() => {
if (fs.existsSync(tempFile)) {
fs.unlinkSync(tempFile);
}
});

it('should load initial data and dynamically serve it', async () => {
await JsonStore.load(tempFile);
expect(JsonStore.getData().posts).toEqual([{ id: 1, title: 'Original Title' }]);

const router = buildDynamicRouter();
const handler = getResourceGetHandler(router);

let jsonResponse: any = null;
const req = {
params: { resource: 'posts' },
query: {}
} as any;
const res = {
setHeader: () => {},
json: (data: any) => {
jsonResponse = data;
}
} as any;

await handler(req, res, () => {});

expect(jsonResponse).toEqual([{ id: 1, title: 'Original Title' }]);
});

it('should reflect updates to existing resources dynamically without recreating router', async () => {
fs.writeFileSync(tempFile, JSON.stringify({
posts: [{ id: 1, title: 'Updated Title' }]
}));

await JsonStore.load(tempFile, true);

const router = buildDynamicRouter();
const handler = getResourceGetHandler(router);

let jsonResponse: any = null;
const req = {
params: { resource: 'posts' },
query: {}
} as any;
const res = {
setHeader: () => {},
json: (data: any) => {
jsonResponse = data;
}
} as any;

await handler(req, res, () => {});
expect(jsonResponse).toEqual([{ id: 1, title: 'Updated Title' }]);
});

it('should dynamically serve newly added resources without recreating router', async () => {
fs.writeFileSync(tempFile, JSON.stringify({
posts: [{ id: 1, title: 'Updated Title' }],
comments: [{ id: 1, body: 'New Comment' }]
}));

await JsonStore.load(tempFile, true);

const router = buildDynamicRouter();
const handler = getResourceGetHandler(router);

let postsResponse: any = null;
const reqPosts = { params: { resource: 'posts' }, query: {} } as any;
const resPosts = {
setHeader: () => {},
json: (data: any) => { postsResponse = data; }
} as any;
await handler(reqPosts, resPosts, () => {});
expect(postsResponse).toEqual([{ id: 1, title: 'Updated Title' }]);

let commentsResponse: any = null;
const reqComments = { params: { resource: 'comments' }, query: {} } as any;
const resComments = {
setHeader: () => {},
json: (data: any) => { commentsResponse = data; }
} as any;

let nextCalled = false;
await handler(reqComments, resComments, () => { nextCalled = true; });

expect(nextCalled).toBe(false);
expect(commentsResponse).toEqual([{ id: 1, body: 'New Comment' }]);
});

it('should fall through to next() for non-existent resources', async () => {
const router = buildDynamicRouter();
const handler = getResourceGetHandler(router);

const req = { params: { resource: 'nonexistent' }, query: {} } as any;
const res = {
setHeader: () => {},
json: () => { throw new Error('Should not call json()'); }
} as any;

let nextCalled = false;
await handler(req, res, () => {
nextCalled = true;
});

expect(nextCalled).toBe(true);
});
});
Loading
Loading