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
159 changes: 134 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,37 +1,146 @@
# Frontend Consumer Test
# zero-mock

This branch tests `zero-mock` as an end-user using the published npm package.
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.8-blue?logo=typescript&logoColor=white)](tsconfig.json)
[![npm version](https://img.shields.io/npm/v/@xirconsss/zero-mock.svg)](https://www.npmjs.com/package/@xirconsss/zero-mock)

## How to run
**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.

1. **Install the CLI globally (or run via npx):**
```bash
npm install -g @xirconsss/zero-mock
```
## Demo

2. **Start the mock server:**
```bash
npx @xirconsss/zero-mock -- --file ./data.json --port 3000
```
![Terminal demo](docs/demo.gif)

With simulated delay (2s) and watch, use **long flags** so `npx`/`npm` never treats `-d` as its own debug flag:
## Installation

```bash
npx @xirconsss/zero-mock -- --file ./data.json --port 3000 --delay 2000 --watch
```
### Global

If you still see `required option '-f, --file' not specified`, use **`npm exec`** instead:
```bash
npm install -g @xirconsss/zero-mock
```

```bash
npm exec -- @xirconsss/zero-mock -- --file ./data.json --port 3000 --delay 2000
```
Run the CLI as **`zero-mock`** (see [Usage](#usage)).

Or install globally and avoid `npx` parsing entirely:
### One-off with npx

```bash
zero-mock --file ./data.json --port 3000 --delay 2000
```
```bash
npx @xirconsss/zero-mock -f ./example/db.json -p 3000
```

3. **Run the Frontend:**
Simply open `index.html` in your browser.
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`).

## Usage

| Flag | Description |
| ---- | ----------- |
| **`-f` / `--file`** | Required. Path to the JSON file. |
| **`-p` / `--port`** | HTTP port (default `3000`, must be 1–65535). |
| **`-d` / `--delay`** | Optional. Delay every request by this many milliseconds. Must be a non-negative integer using digits only (default `0`). |
| **`-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. |

Examples:

```bash
zero-mock -f ./data.json -p 3000 -d 200
zero-mock -f ./data.json -w
```

## Quick start

From the repo root (or any directory containing the example file):

```bash
npx @xirconsss/zero-mock -f ./example/db.json -p 3000
```

Optional:

```bash
npx @xirconsss/zero-mock -f ./example/db.json -p 3000 -d 200 -w
```

In another terminal:

```bash
curl http://localhost:3000/users
curl http://localhost:3000/users/1
```

The server logs the exact URLs for each collection when it starts.

## Development

From a clone: `npm install`, then `npm run build` (or `npm run dev` with `ts-node`). Run the built CLI with:

```bash
node dist/index.js -f ./example/db.json -p 3000
```

Add `-d` / `-w` the same way as the published CLI.

## Features

- **Zero-config** — one JSON file defines your API surface; no schemas or generators to run.
- **Full CRUD REST API** — `GET`, `POST`, `PUT`, `PATCH`, and `DELETE` per collection, with CORS and JSON bodies enabled.
- **Atomic file persistence** — writes go through a temp file and rename, with serialized saves so concurrent requests do not corrupt the file.
- **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**.
- **Request logging** — each finished request logs as `[METHOD] <path> - <status>` (path is Express `req.path`, no query string).
- **Optional delay** — `-d` adds a fixed pause before route handling (after JSON body parsing).
- **List filtering and pagination** — see [List GET](#list-get) on `GET /{resource}`.
- **Watch mode** — `-w` reloads data from disk on file change without restarting the process (see [Usage](#usage)).

## Auto-generated API

Each **top-level key** in your JSON (e.g. `users`, `posts`) becomes a **resource** name. Replace `{resource}` with that key and `{id}` with a row’s `id` (string params match numeric ids loosely).

| Method | Path | Description |
| -------- | -------------------- | ----------- |
| `GET` | `/{resource}` | List items (optionally [filtered and paginated](#list-get)). |
| `POST` | `/{resource}` | Create an item; server assigns `id` and returns `201` with the new body. |
| `GET` | `/{resource}/{id}` | Return one item by `id`; `404` if missing. |
| `PUT` | `/{resource}/{id}` | Replace the item; `id` in the URL wins; `404` if missing. |
| `PATCH` | `/{resource}/{id}` | Shallow-merge fields into the item; `404` if missing. |
| `DELETE` | `/{resource}/{id}` | Remove the item; `204` on success; `404` if missing. |

Invalid JSON bodies (non-objects for write routes) receive **`400`** with a JSON error message. Persistence failures surface as **`500`**.

### List GET

**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.

**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).

Examples using [example/db.json](example/db.json):

```bash
curl 'http://localhost:3000/users?role=admin'
curl 'http://localhost:3000/users?_page=1&_limit=2'
```

## JSON file shape

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).

## Publishing to npm (maintainers)

**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.

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).
2. **GitHub:** repo → **Settings** → **Environments** → **`NPM_TOKEN`** → add secret **`NPM_TOKEN`** (see token steps below). The workflow uses that environment on each run.
3. Push conventional commits to **`main`**, wait for **Publish to npm** to finish. Check with `npm view @xirconsss/zero-mock version`.

**Token on npm (required for CI):**

1. Create a classic **[Automation](https://docs.npmjs.com/creating-and-viewing-access-tokens#creating-classic-tokens)** token, **or** a **[granular access token](https://docs.npmjs.com/creating-and-viewing-access-tokens#creating-granular-access-tokens)** with **read and write** on **`@xirconsss/zero-mock`** (and org **`xirconsss`** if npm asks).
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).
3. Paste the token into the **`NPM_TOKEN`** environment secret on GitHub.

**Optional — OIDC trusted publishing:** You can later move to [npm trusted publishing](https://docs.npmjs.com/trusted-publishers) and drop the secret; if you see **`E404`** on `PUT` with OIDC, the Trusted Publisher settings on npm (repo, workflow filename, environment name) do not match this workflow—token auth avoids that until it is configured correctly.

## License

MIT - see [LICENSE](LICENSE).

## Links

- **Repository:** [github.com/xircons/zero-mock](https://github.com/xircons/zero-mock)
- **Issues:** [github.com/xircons/zero-mock/issues](https://github.com/xircons/zero-mock/issues)
43 changes: 42 additions & 1 deletion package-lock.json

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

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,11 @@
},
"homepage": "https://github.com/xircons/zero-mock#readme",
"dependencies": {
"chokidar": "^4.0.3",
"commander": "^13.1.0",
"cors": "^2.8.5",
"express": "^4.21.2"
"express": "^4.21.2",
"zod": "^4.4.3"
},
"devDependencies": {
"@commitlint/cli": "^21.0.2",
Expand Down
46 changes: 28 additions & 18 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env node
import { watch } from "fs";
import chokidar from "chokidar";
import { Command } from "commander";
import { JsonStore } from "./store/jsonStore";
import { bootstrap } from "./server/bootstrap";
Expand All @@ -9,6 +9,9 @@ type CliOpts = {
port: string;
delay: string;
watch: boolean;
corsOrigin?: string;
corsMethods?: string;
corsCredentials?: boolean;
};

function parseDelayMs(raw: string): number | null {
Expand All @@ -20,31 +23,30 @@ function parseDelayMs(raw: string): number | null {
}

function startFileWatcher(filePath: string): void {
let reloadInFlight = false;
let reloadTimeout: ReturnType<typeof setTimeout> | null = null;

const reload = async (): Promise<void> => {
if (reloadInFlight) {
return;
}
reloadInFlight = true;
try {
await JsonStore.load(filePath);
console.log(`[watch] Reloaded "${filePath}".`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`[watch] Could not reload "${filePath}": ${message}`);
} finally {
reloadInFlight = false;
}
};

try {
watch(filePath, { persistent: true }, () => {
void reload();
chokidar
.watch(filePath, { persistent: true, ignoreInitial: true })
.on("change", () => {
if (reloadTimeout) clearTimeout(reloadTimeout);
reloadTimeout = setTimeout(() => void reload(), 100);
})
.on("error", (err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[watch] Watcher error: ${msg}`);
});
console.log(`[watch] Watching "${filePath}" for changes.`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`[watch] Failed to watch "${filePath}": ${message}`);
}

console.log(`[watch] Watching "${filePath}" for changes.`);
}

const program = new Command();
Expand All @@ -56,6 +58,9 @@ program
.option("-p, --port <number>", "HTTP port", "3000")
.option("-d, --delay <ms>", "delay each request by this many ms (0 = off)", "0")
.option("-w, --watch", "reload JSON from disk when the file changes", false)
.option("--cors-origin <origins>", "comma-separated list of allowed origins (e.g., http://localhost:3000)", "*")
.option("--cors-methods <methods>", "comma-separated list of allowed HTTP methods", "GET,HEAD,PUT,PATCH,POST,DELETE")
.option("--cors-credentials", "enable CORS credentials (cookies, authorization headers)", false)
.action(async (opts: CliOpts) => {
const port = Number.parseInt(opts.port, 10);
if (Number.isNaN(port) || port < 1 || port > 65535) {
Expand Down Expand Up @@ -83,7 +88,12 @@ program
}

try {
await bootstrap(port, { delayMs });
await bootstrap(port, {
delayMs,
corsOrigin: opts.corsOrigin,
corsMethods: opts.corsMethods,
corsCredentials: opts.corsCredentials,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Failed to start server: ${message}`);
Expand All @@ -96,4 +106,4 @@ program
}
});

program.parse(process.argv);
program.parse(process.argv);
13 changes: 11 additions & 2 deletions src/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => {
return;
}
const message = err instanceof Error ? err.message : String(err);
res.status(500).json({ error: message });
res.status(500).json({ error: "INTERNAL_SERVER_ERROR", message, statusCode: 500 });
};

export type CreateAppOptions = {
delayMs: number;
corsOrigin?: string;
corsMethods?: string;
corsCredentials?: boolean;
};

function requestLoggingMiddleware(req: Request, res: Response, next: NextFunction): void {
Expand All @@ -34,7 +37,13 @@ function delayMiddleware(delayMs: number) {

export function createApp(options: CreateAppOptions): Application {
const app = express();
app.use(cors());

app.use(cors({
origin: options.corsOrigin && options.corsOrigin !== "*" ? options.corsOrigin.split(",") : "*",
methods: options.corsMethods || "GET,HEAD,PUT,PATCH,POST,DELETE",
credentials: options.corsCredentials || false,
}));

app.use(express.json());
app.use(requestLoggingMiddleware);
app.use(delayMiddleware(options.delayMs));
Expand Down
Loading
Loading