From b122de518c8d2d14886e8e20ebf78b36f7336400 Mon Sep 17 00:00:00 2001 From: Peter Meier Date: Tue, 23 Jun 2026 12:44:31 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20mockly-hono=20=E2=80=94=20file-based=20?= =?UTF-8?q?mock=20API=20server=20(Hono)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File path = route. JSON/JSON5, dynamic .ts handlers + faker, templating, stateful CRUD (pagination/sort/persist), __variants, sequence, chaos, rate-limit, auth, validation, per-route + global proxy/record, splat routes, X-Scenario overlays, SSE. Visual docs (Scalar) at /__docs from a live OpenAPI 3 spec. OpenAPI import script. Zero-build via tsx. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 19 + .gitignore | 3 + LICENSE | 21 + README.md | 417 ++++++++++++ package-lock.json | 619 ++++++++++++++++++ package.json | 24 + routes/__test/README.md | 58 ++ routes/__test/_lib.ts | 6 + routes/__test/created.post.201.json | 5 + routes/__test/echo.post.ts | 7 + routes/__test/files/[...path].json | 4 + routes/__test/flaky.json | 7 + routes/__test/jitter.json | 5 + routes/__test/limited.json | 5 + routes/__test/products.crud.json | 5 + routes/__test/profile.json5 | 19 + routes/__test/proxied.json | 4 + routes/__test/quote.get.ts | 7 + routes/__test/roulette.json | 5 + routes/__test/secret.json | 5 + routes/__test/signup.post.json | 5 + routes/__test/slow.json | 5 + routes/__test/static.json | 9 + routes/__test/teapot.418.json | 3 + routes/__test/template.json | 8 + routes/__test/ticker.sse.json | 8 + routes/__test/tpl/echo.json | 8 + routes/__test/tpl/faker.json | 22 + routes/__test/tpl/list.json | 8 + routes/__test/tpl/typed.json | 7 + routes/__test/tpl/user/[id].json | 7 + routes/__test/users/[id].get.ts | 20 + .../_scenarios/broken/__test/static.503.json | 3 + routes/_tags.json5 | 8 + routes/account.json5 | 18 + routes/echo.post.ts | 6 + routes/error.404.json | 3 + routes/pages/homepage.json | 4 + routes/todos.crud.json | 4 + routes/users.post.201.json | 5 + routes/users/[id].get.ts | 16 + scripts/from-openapi.ts | 102 +++ src/index.ts | 162 +++++ src/openapi-gen.ts | 127 ++++ src/parse.ts | 7 + src/proxy.ts | 54 ++ src/registry.ts | 164 +++++ src/respond.ts | 295 +++++++++ src/runtime.ts | 26 + src/store.ts | 93 +++ src/template.ts | 58 ++ src/validate.ts | 38 ++ tsconfig.json | 15 + 53 files changed, 2563 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 routes/__test/README.md create mode 100644 routes/__test/_lib.ts create mode 100644 routes/__test/created.post.201.json create mode 100644 routes/__test/echo.post.ts create mode 100644 routes/__test/files/[...path].json create mode 100644 routes/__test/flaky.json create mode 100644 routes/__test/jitter.json create mode 100644 routes/__test/limited.json create mode 100644 routes/__test/products.crud.json create mode 100644 routes/__test/profile.json5 create mode 100644 routes/__test/proxied.json create mode 100644 routes/__test/quote.get.ts create mode 100644 routes/__test/roulette.json create mode 100644 routes/__test/secret.json create mode 100644 routes/__test/signup.post.json create mode 100644 routes/__test/slow.json create mode 100644 routes/__test/static.json create mode 100644 routes/__test/teapot.418.json create mode 100644 routes/__test/template.json create mode 100644 routes/__test/ticker.sse.json create mode 100644 routes/__test/tpl/echo.json create mode 100644 routes/__test/tpl/faker.json create mode 100644 routes/__test/tpl/list.json create mode 100644 routes/__test/tpl/typed.json create mode 100644 routes/__test/tpl/user/[id].json create mode 100644 routes/__test/users/[id].get.ts create mode 100644 routes/_scenarios/broken/__test/static.503.json create mode 100644 routes/_tags.json5 create mode 100644 routes/account.json5 create mode 100644 routes/echo.post.ts create mode 100644 routes/error.404.json create mode 100644 routes/pages/homepage.json create mode 100644 routes/todos.crud.json create mode 100644 routes/users.post.201.json create mode 100644 routes/users/[id].get.ts create mode 100644 scripts/from-openapi.ts create mode 100644 src/index.ts create mode 100644 src/openapi-gen.ts create mode 100644 src/parse.ts create mode 100644 src/proxy.ts create mode 100644 src/registry.ts create mode 100644 src/respond.ts create mode 100644 src/runtime.ts create mode 100644 src/store.ts create mode 100644 src/template.ts create mode 100644 src/validate.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fb2048b --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# server port (3000 collides with RustyCMS in this workspace -> 4100) +PORT=4100 +# default response delay (ms) when a route sets none +REQUEST_DELAY=0 +# gzip/deflate responses +COMPRESS=true +# CORS allow-origin ( * or https://app.example.com ) +CORS_ORIGIN=* +# override routes directory (default ./routes) +# ROUTES_DIR=./routes +# chaos engineering: fraction of requests that fail (0 = off) +CHAOS_RATE=0 +CHAOS_STATUS=500 +# proxy unmatched requests to a real upstream ( empty = off ) +PROXY_TARGET= +# when proxying, save upstream json responses as mock files +RECORD=false +# persist CRUD mutations back to the *.crud.json seed file (survives restart) +PERSIST=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e768dd6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +.env +*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7e7b7b5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Peter Meier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..13f4316 --- /dev/null +++ b/README.md @@ -0,0 +1,417 @@ +# mockly-hono + +File-based mock API server. Drop files in `routes/`, the file path becomes the +URL. [Hono](https://hono.dev) core → runs on Node / Bun / Deno / edge. Zero build +(runs `.ts` directly via tsx). + +Successor to the original Express `mockly`. Same file-based idea, much more power. + +## Contents + +- [Quick start](#quick-start) +- [Routing](#routing) — path, method, status from the filename +- [JSON / JSON5 mocks](#json--json5-mocks) — control keys +- [Templating](#templating) — `{{faker.x}}` / `{{params.x}}` in plain JSON +- [Behavior control keys](#behavior-control-keys) — sequence · chaos · rate-limit · auth · validate · proxy +- [Dynamic mocks](#dynamic-mocks-ts--js) — JS/TS handlers + faker +- [Stateful CRUD](#stateful-crud-crudjson) — incl. pagination & sort +- [Conditional responses](#conditional-responses-__variants) +- [Splat routes](#splat-routes-rest) +- [Scenarios](#scenarios-x-scenario) +- [SSE streaming](#sse-streaming-ssejson) +- [Proxy & record](#proxy--record) +- [OpenAPI import](#openapi-import) +- [Visual docs](#visual-docs-__docs) +- [Built-in endpoints](#built-in-endpoints) +- [Config](#config-env) +- [Workspace alias](#workspace-alias) +- [Project layout](#project-layout) +- [What changed vs original mockly](#what-changed-vs-original-mockly) + +## Quick start + +```sh +cp .env.example .env +npm install +npm run dev # watch + hot reload (tsx watch) +# or: source alias.sh && web-mock dev +``` + +```sh +curl localhost:4100/__routes # list every mock +curl localhost:4100/pages/homepage # serve routes/pages/homepage.json +``` + +Scripts: `npm run dev` (watch) · `npm run start` · `npm run openapi -- ` · +`npm run lint` (tsc typecheck). + +## Routing + +Directory path + filename = URL. The filename encodes **method** and **status** +via dotted tokens. No method token = `GET` (back-compat with original mockly). + +| File | Route | +|---|---| +| `routes/pages/homepage.json` | `GET /pages/homepage` | +| `routes/index.json` | `GET /` | +| `routes/users.post.json` | `POST /users` | +| `routes/user.404.json` | `GET /user` → 404 | +| `routes/users.post.201.json` | `POST /users` → 201 | +| `routes/users/[id].get.ts` | `GET /users/:id` (path param `id`) | + +- **Method** token: `get post put patch delete options head`. +- **Status** token: any 3-digit number → default status for that route. +- **Path params**: a segment named `[id]` (dir or file) captures into `params.id`. +- Concrete routes beat param routes; `head` falls back to the matching `get`. +- A **single** leading `_` (e.g. `_partials/`, `_lib.ts`) or a `.` = ignored — + use for shared data/helpers you import from handlers. A **double** `__` (e.g. + `__test/`) **is** served — handy for grouping. See `routes/__test/` for one + worked example per feature. + +Supported file kinds: `.json`, `.json5`, `.js`, `.mjs`, `.ts`, and `*.crud.json`. + +## JSON / JSON5 mocks + +Plain JSON/JSON5 → returned as-is with status 200. Optional `__` control keys +shape the response and are **stripped from the body** (original mockly leaked +`__delayed` into the payload — fixed here): + +```json5 +{ + __status: 201, + __delay: 300, // ms; __delayed is an alias + __headers: { Location: "/users/42" }, + __body: { id: 42, created: true } // explicit body +} +``` + +`__delay` may be a `[min, max]` range for random jitter (a fresh value per +request): `{ __delay: [200, 1200] }`. Works in `__variants` and `.ts` handlers +too, and the global `REQUEST_DELAY` accepts a range string (`REQUEST_DELAY=200-800`). + +Without `__body`, every non-`__` key is the body: + +```json +{ "title": "Homepage", "blocks": ["hero", "features"] } +``` + +`.json5` allows comments, trailing commas, unquoted keys; `.json` is parsed +leniently too (JSON5 is a superset). + +## Templating + +`{{ ... }}` placeholders inside any JSON body are resolved per request — no +`.ts` handler needed: + +```json +{ + "name": "{{faker.person.fullName}}", + "lucky": "{{faker.number.int}}", + "echo": "you sent q={{query.q}}", + "id": "{{params.id}}" +} +``` + +Sources: `faker.*` (zero-arg methods), `params.*`, `query.*`, `headers.*`. If a +string is *exactly* one placeholder, the typed value is kept (`{{faker.number.int}}` +→ a number, not a string). + +## Behavior control keys + +Drop any of these into a JSON route to simulate real-world API behavior. All are +stripped from the body. + +| Key | Effect | +|---|---| +| `__sequence: [r1, r2, …]` | a different response each call, cycling. Items are raw bodies or `{status,headers,delay,body}`. Great for poll-then-ready / fail-then-succeed. | +| `__chaos: { rate, status, body? }` | fail this route randomly (`rate` 0–1). Per-route version of `CHAOS_RATE`. | +| `__rateLimit: { max, windowMs }` | after `max` requests per sliding window → `429` with `Retry-After`. | +| `__auth: "bearer"` or `{ token }` | require `Authorization: Bearer …` (any token, or an exact one) → `401` otherwise. | +| `__validate: { field: "type" }` | on POST/PUT/PATCH, check the body → `422 { errors }`. Types: `string number boolean array object any`, trailing `?` = optional. | +| `__proxy: "https://api.real.com"` | forward this one route to a real upstream (same path+query), tagged `X-Mock-Proxy`. Partial mocking. | +| `__doc: "text"` or `{ summary, description }` | sidebar label + markdown description shown in the visual docs (`/__docs`). | + +```json5 +// poll endpoint that becomes ready on the 3rd call +{ __sequence: [ + { status: 202, body: { state: 'pending' } }, + { status: 202, body: { state: 'pending' } }, + { status: 200, body: { state: 'ready' } }, +] } +``` + +## Dynamic mocks (`.ts` / `.js`) + +Export a default function for request-aware responses. Return a raw body, or a +`{ status, headers, delay, body }` envelope. + +```ts +import type { MockContext } from '../../src/respond.ts' + +// GET /users/:id -> deterministic synthetic user +export default function ({ params, query, faker }: MockContext) { + faker.seed(Number(params.id) || 1) + return { + id: params.id, + name: faker.person.fullName(), + verbose: query.verbose === '1' ? faker.lorem.paragraph() : undefined, + } +} +``` + +`MockContext`: + +| Field | Type | Notes | +|---|---|---| +| `params` | `Record` | path params | +| `query` | `Record` | query string | +| `headers` | `Record` | request headers | +| `method` | `string` | HTTP method | +| `body` | `unknown` | parsed JSON body (write methods) | +| `faker` | faker instance | [`@faker-js/faker`](https://fakerjs.dev) | + +Handlers hot-reload on save (cache-busted import) — no restart. + +```sh +curl "localhost:4100/users/7?verbose=1" +# {"id":"7","name":"Patty Reilly IV","verbose":"Vicinus acidus ..."} +``` + +## Stateful CRUD (`*.crud.json`) + +A seed file (a JSON/JSON5 **array**) expands into a full REST resource backed by +an in-memory store. `routes/todos.crud.json` → + +| Method | Route | Op | +|---|---|---| +| GET | `/todos` | list — filters `?field=value`, sort `?_sort=price&_order=desc`, paginate `?_page=2&_limit=10` (or `?_start&_end`), `X-Total-Count` header | +| POST | `/todos` | create (auto-increment `id`) → 201 | +| GET | `/todos/:id` | read (404 if missing) | +| PUT | `/todos/:id` | replace | +| PATCH | `/todos/:id` | merge | +| DELETE | `/todos/:id` | delete → 204 | + +State lives in memory and survives hot reloads; a server restart resets it to the +seed — unless `PERSIST=true`, which writes mutations back to the `*.crud.json` +file so they survive restarts. + +```sh +curl -X POST localhost:4100/todos -H 'content-type: application/json' \ + -d '{"title":"new","done":false}' # -> {"title":"new","done":false,"id":3} +curl "localhost:4100/todos?done=true" # filtered list +curl -X PATCH localhost:4100/todos/3 -d '{"done":true}' -H 'content-type: application/json' +curl -X DELETE localhost:4100/todos/1 -i # -> 204 +``` + +## Conditional responses (`__variants`) + +Pick a response by matching the request. The first variant whose `when` matches +wins; a variant without `when` is the fallback. Matchers are **subset** matches +over `query` / `headers` (case-insensitive) / `params` / `body`. + +```json5 +{ __variants: [ + { when: { query: { role: 'admin' } }, body: { seats: 999 } }, + { when: { headers: { 'x-tier': 'pro' } }, body: { seats: 25 } }, + { body: { seats: 1 } }, // fallback +] } +``` + +```sh +curl localhost:4100/account # {"seats":1} +curl "localhost:4100/account?role=admin" # {"seats":999} +curl localhost:4100/account -H 'x-tier: pro' # {"seats":25} +``` + +A variant may also set `status`, `headers`, `delay`. If nothing matches and there +is no fallback → 404. + +## Splat routes (`[...rest]`) + +A `[...rest]` segment catches any remaining path depth into one param: + +``` +routes/files/[...path].json → GET /files/a/b/c.txt (params.path = "a/b/c.txt") +``` + +Splat routes are matched **last**, so concrete and `[id]` routes always win first. +Combine with templating: `{ "path": "{{params.path}}" }`. + +## Scenarios (`X-Scenario`) + +Overlay a whole alternate route set without touching the base. Put files under +`routes/_scenarios//…` mirroring the normal paths. A request with header +`X-Scenario: ` is matched against that overlay first, then falls back to the +base routes. + +``` +routes/_scenarios/broken/users.500.json # X-Scenario: broken → GET /users = 500 +routes/_scenarios/empty/users.json # X-Scenario: empty → GET /users = [] +``` + +Flip your frontend between happy / empty / error states with one header. Active +scenario names are printed at startup. + +## SSE streaming (`*.sse.json`) + +A `*.sse.json` file streams Server-Sent Events (`text/event-stream`): + +```json5 +{ + loop: false, // repeat the sequence? + events: [ + { event: 'tick', id: 1, data: { n: 1 }, delay: 150 }, + { event: 'done', id: 2, data: { n: 2 }, delay: 150 }, + ], +} +``` + +`delay` waits before each event; `data` is JSON-encoded automatically. The stream +ends after the events (or loops until the client disconnects when `loop: true`). +Consume with `new EventSource('/ticker')` or `curl -N`. + +## Proxy & record + +Set `PROXY_TARGET` and any **unmatched** request forwards to a real upstream +(response tagged `X-Mock-Proxy`). With `RECORD=true`, JSON responses are saved as +mock files — record once, replay forever; the recorded route is served locally on +the next request. + +```sh +PROXY_TARGET=https://api.example.com RECORD=true npm run dev +# first GET /v1/users -> proxied to upstream, saved as routes/v1/users.json +# second GET /v1/users -> served locally (X-Mock-File header) +``` + +Only successful JSON responses are recorded; non-GET methods get a `.` +filename suffix. + +## OpenAPI import + +Generate route files from an OpenAPI 3 spec (json or yaml): + +```sh +npm run openapi -- path/to/openapi.yaml [outDir] +# or: web-mock openapi path/to/openapi.yaml +``` + +Each path × method becomes a file. The body is the response `example`, the first +of `examples`, or a value synthesized from the schema (`$ref`, `allOf`, `oneOf`, +formats like `date-time`/`email`/`uuid` handled). Non-200 success codes go in the +filename (e.g. `pets.post.201.json`). Defaults to `./routes`. + +## Visual docs (`/__docs`) + +Open **`http://localhost:4100/__docs`** for a live, interactive API reference +([Scalar](https://github.com/scalar/scalar), loaded from CDN). It renders every +route — methods, path params, example responses — with a built-in "try it" +client. No build step; the page reads the live spec. + +Add your own per-route docs: + +- **JSON routes** — a `__doc` key (string = description, or `{ summary, description }`; + markdown, stripped from the response): + ```json5 + { __doc: { summary: 'Create user', description: 'Returns **201**.' }, __status: 201, __body: { id: 1 } } + ``` +- **`.ts`/`.js` handlers** — `export const doc = 'text'` or `{ summary, description }` + alongside the default handler. +- **Group descriptions** — a `routes/_tags.json5` mapping the first path segment to + text, shown atop each sidebar section: + ```json5 + { todos: 'Demo CRUD resource.', users: 'User endpoints.' } + ``` + +The underlying spec is generated from the route files on every request at +**`GET /__openapi.json`** (OpenAPI 3.0.3). Examples come from the actual mock +bodies: JSON files use their payload, CRUD routes show the seed data, `__variants` +use the first variant, dynamic `.ts` handlers are marked `(dynamic)`. + +Prefer Swagger UI instead of Scalar? Point it at `/__openapi.json`, or swap the +two ` + +`), +) + +app.all('*', async (c) => { + if (CHAOS_RATE > 0 && Math.random() < CHAOS_RATE) { + return c.json({ error: 'chaos', injected: true }, CHAOS_STATUS as any) + } + const pathname = new URL(c.req.url).pathname + const scen = c.req.header('x-scenario') + let m = scen && scenarios.has(scen) ? match(scenarios.get(scen)!, c.req.method, pathname) : null + if (!m) m = match(routes, c.req.method, pathname) + if (!m) { + if (PROXY_TARGET) { + try { + const r = await proxyPass(c, { target: PROXY_TARGET, record: RECORD, routesDir: ROUTES_DIR }) + if (RECORD) rescan() + return r + } catch (e) { + return c.json({ error: 'proxy failed', target: PROXY_TARGET, detail: String(e) }, 502) + } + } + return c.json({ error: 'no mock', path: pathname, method: c.req.method }, 404) + } + try { + return await respond(c, m, { defaultDelay: DEFAULT_DELAY, faker, routesDir: ROUTES_DIR }) + } catch (e) { + console.error('mock error', e) + return c.json({ error: 'mock handler failed', detail: String(e) }, 500) + } +}) + +console.log(` +# mockly-hono +routes dir : ${ROUTES_DIR} +routes : ${routes.length} +port : ${PORT} +delay : ${Array.isArray(DEFAULT_DELAY) ? `${DEFAULT_DELAY[0]}-${DEFAULT_DELAY[1]}ms (random)` : `${DEFAULT_DELAY}ms`} +compress : ${COMPRESS} +cors : ${CORS_ORIGIN} +chaos : ${CHAOS_RATE > 0 ? `${CHAOS_RATE * 100}% -> ${CHAOS_STATUS}` : 'off'} +proxy : ${PROXY_TARGET ? `${PROXY_TARGET}${RECORD ? ' (recording)' : ''}` : 'off'} +introspect : GET /__routes +docs : GET /__docs (spec: /__openapi.json) +`) + +// OSC 8 terminal hyperlink — cmd/ctrl-clickable in iTerm2, VS Code, modern terminals +const link = (url: string, label = url) => `\x1b]8;;${url}\x1b\\${label}\x1b]8;;\x1b\\` +const BOLD = '\x1b[1m', CYAN = '\x1b[36m' + +printRoutes(routes) +const base = `http://localhost:${PORT}` +console.log(` +# LISTENING ${link(base)} +${BOLD}${CYAN}➜ docs ${link(base + '/__docs')}${RESET} + spec ${link(base + '/__openapi.json')} +`) + +serve({ fetch: app.fetch, port: PORT }) diff --git a/src/openapi-gen.ts b/src/openapi-gen.ts new file mode 100644 index 0000000..571766c --- /dev/null +++ b/src/openapi-gen.ts @@ -0,0 +1,127 @@ +import { pathToFileURL } from 'node:url' +import { join } from 'node:path' +import type { RouteEntry } from './registry.ts' +import { parseDataFile } from './parse.ts' +import { store } from './store.ts' + +/** Best-effort example body + status for a route, used to fill the OpenAPI doc. */ +function exampleFor(entry: RouteEntry): { status: number; body: unknown; dynamic: boolean } { + const status = entry.status ?? (entry.crud?.op === 'create' ? 201 : entry.crud?.op === 'delete' ? 204 : 200) + + if (entry.kind === 'crud') { + const c = entry.crud!.collection + const items = store.list(c) + if (entry.crud!.op === 'list') return { status, body: items, dynamic: false } + return { status, body: items[0] ?? { id: 1 }, dynamic: false } + } + + if (entry.kind === 'module') return { status, body: undefined, dynamic: true } + + try { + const raw = parseDataFile(entry.file) as Record + if (raw && Array.isArray((raw as any).__variants)) { + const v = (raw as any).__variants[0] ?? {} + return { status: v.status ?? status, body: v.body, dynamic: true } + } + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + if ('__body' in raw) return { status, body: raw.__body, dynamic: false } + const out: Record = {} + for (const k of Object.keys(raw)) if (!k.startsWith('__')) out[k] = raw[k] + return { status, body: out, dynamic: false } + } + return { status, body: raw, dynamic: false } + } catch { + return { status, body: null, dynamic: false } + } +} + +/** :id -> {id} for OpenAPI path templating. */ +function toOpenApiPath(template: string) { + return template.replace(/:([^/]+)/g, '{$1}') +} + +type Doc = { summary?: string; description?: string } +const normalizeDoc = (doc: unknown): Doc => + typeof doc === 'string' ? { description: doc } : doc && typeof doc === 'object' ? (doc as Doc) : {} + +/** Read an optional author description: __doc in JSON, or `export const doc` in modules. */ +async function readDoc(entry: RouteEntry): Promise { + try { + if (entry.kind === 'json') return normalizeDoc((parseDataFile(entry.file) as any)?.__doc) + if (entry.kind === 'module') { + const mod = await import(pathToFileURL(entry.file).href + `?doc=${Date.now()}`) + return normalizeDoc(mod.doc) + } + } catch { + /* ignore */ + } + return {} +} + +/** Optional tag (group) descriptions from routes/_tags.json5: { "todos": "…" }. */ +function readTags(routesDir: string): Record { + for (const name of ['_tags.json5', '_tags.json']) { + try { + return parseDataFile(join(routesDir, name)) as Record + } catch { + /* try next */ + } + } + return {} +} + +export async function buildOpenApi(routes: RouteEntry[], routesDir = '', title = 'mockly-hono') { + const paths: Record> = {} + const usedTags = new Set() + + for (const entry of routes) { + if (entry.method === 'head' || entry.method === 'options') continue + const path = toOpenApiPath(entry.template) + const params = entry.segments + .filter((s) => s.param) + .map((s) => ({ name: s.param, in: 'path', required: true, schema: { type: 'string' } })) + + const { status, body, dynamic } = exampleFor(entry) + const content = body === undefined ? undefined : { 'application/json': { example: body } } + + const segs = entry.template.split('/').filter(Boolean) + const tag = segs[0] ?? 'root' + usedTags.add(tag) + // label shown in the sidebar = path relative to its tag group + const rest = segs.slice(1).join('/') + const label = rest ? '/' + rest : entry.template + const kindNote = `${entry.kind}${dynamic ? ' (dynamic)' : ''}${entry.status ? ` · default ${entry.status}` : ''}` + + const doc = await readDoc(entry) + const autoDesc = `Mock route \`${entry.method.toUpperCase()} ${entry.template}\` — kind: ${kindNote}` + + const op: Record = { + tags: [tag], + summary: doc.summary ?? label, + operationId: `${entry.method}_${segs.join('_') || 'root'}`, + description: doc.description ? `${doc.description}\n\n${autoDesc}` : autoDesc, + responses: { + [String(status)]: { description: dynamic ? 'example (varies at runtime)' : 'mock response', ...(content ? { content } : {}) }, + }, + } + if (params.length) op.parameters = params + if (['post', 'put', 'patch'].includes(entry.method)) { + op.requestBody = { required: false, content: { 'application/json': { schema: { type: 'object' } } } } + } + + paths[path] ??= {} + paths[path][entry.method] = op + } + + const tagDescriptions = readTags(routesDir) + const tags = [...usedTags].sort().map((name) => + tagDescriptions[name] ? { name, description: tagDescriptions[name] } : { name }, + ) + + return { + openapi: '3.0.3', + info: { title, version: '2.0.0', description: 'Auto-generated from the mockly route files.' }, + tags, + paths, + } +} diff --git a/src/parse.ts b/src/parse.ts new file mode 100644 index 0000000..8c52ef1 --- /dev/null +++ b/src/parse.ts @@ -0,0 +1,7 @@ +import { readFileSync } from 'node:fs' +import JSON5 from 'json5' + +/** Parse .json or .json5 — json5 is a superset so we use it for both. */ +export function parseDataFile(file: string): unknown { + return JSON5.parse(readFileSync(file, 'utf8')) +} diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 0000000..b963f2e --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,54 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import type { Context } from 'hono' + +/** + * Forward unmatched requests to a real upstream (PROXY_TARGET). + * With record=true, persist the upstream JSON response as a mock file so the + * next run serves it locally — record-once, replay-forever. + */ +export async function proxyPass( + c: Context, + opts: { target: string; record: boolean; routesDir: string }, +): Promise { + const reqUrl = new URL(c.req.url) + const upstream = new URL(reqUrl.pathname + reqUrl.search, opts.target) + + const init: RequestInit = { + method: c.req.method, + headers: stripHopHeaders(c.req.raw.headers), + } + if (!['GET', 'HEAD'].includes(c.req.method)) init.body = await c.req.raw.arrayBuffer() + + const res = await fetch(upstream, init) + const buf = await res.arrayBuffer() + + if (opts.record && res.ok && (res.headers.get('content-type') ?? '').includes('json')) { + try { + recordMock(opts.routesDir, reqUrl.pathname, c.req.method, JSON.parse(Buffer.from(buf).toString('utf8'))) + } catch { + /* non-json body — skip recording */ + } + } + + const headers = new Headers(res.headers) + headers.set('X-Mock-Proxy', upstream.href) + if (opts.record) headers.set('X-Mock-Recorded', 'true') + return new Response(buf, { status: res.status, headers }) +} + +function recordMock(routesDir: string, pathname: string, method: string, body: unknown) { + const segs = pathname.split('/').filter(Boolean) + const base = segs.length ? segs.join('/') : 'index' + const suffix = method.toLowerCase() === 'get' ? '' : `.${method.toLowerCase()}` + const file = join(routesDir, `${base}${suffix}.json`) + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, JSON.stringify(body, null, 2) + '\n') + console.log(`⤓ recorded ${method} ${pathname} -> ${file}`) +} + +function stripHopHeaders(h: Headers): Headers { + const out = new Headers(h) + for (const k of ['host', 'connection', 'content-length', 'accept-encoding']) out.delete(k) + return out +} diff --git a/src/registry.ts b/src/registry.ts new file mode 100644 index 0000000..98d184e --- /dev/null +++ b/src/registry.ts @@ -0,0 +1,164 @@ +import { readdirSync, statSync, watch } from 'node:fs' +import { join, relative, sep } from 'node:path' +import { store } from './store.ts' + +export type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'options' | 'head' +const METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'head']) + +export type CrudOp = 'list' | 'create' | 'get' | 'put' | 'patch' | 'delete' + +export interface RouteEntry { + file: string + method: HttpMethod + status?: number + template: string + segments: { value: string; param?: string; splat?: boolean }[] + kind: 'json' | 'module' | 'crud' | 'sse' + /** present when kind === 'crud' */ + crud?: { collection: string; op: CrudOp } +} + +export interface MatchResult { + entry: RouteEntry + params: Record +} + +const DATA_EXT = /\.(json5?)$/i +const MOD_EXT = /\.(m?js|ts)$/i +const CRUD_RE = /\.crud\.json5?$/i + +function parseFilename(name: string) { + const parts = name.split('.') + const ext = parts.pop()!.toLowerCase() + let kind: RouteEntry['kind'] = ext === 'json' || ext === 'json5' ? 'json' : 'module' + let method: HttpMethod = 'get' + let status: number | undefined + // .sse.json -> streaming route + if (kind === 'json' && parts[parts.length - 1]?.toLowerCase() === 'sse') { + kind = 'sse' + parts.pop() + } + while (parts.length > 1) { + const tail = parts[parts.length - 1].toLowerCase() + if (METHODS.has(tail as HttpMethod)) method = parts.pop() as HttpMethod + else if (/^\d{3}$/.test(tail)) status = Number(parts.pop()) + else break + } + return { base: parts.join('.'), method, status, kind } +} + +function toSegments(relPath: string, base: string): RouteEntry['segments'] { + const dirParts = relPath.split(sep).slice(0, -1) + const all = [...dirParts, base].filter((p) => p && p !== 'index') + return all.map((value) => { + const splat = value.match(/^\[\.\.\.(.+)\]$/) + if (splat) return { value, param: splat[1], splat: true } + const m = value.match(/^\[(.+)\]$/) + return m ? { value, param: m[1] } : { value } + }) +} + +function segTemplate(segments: RouteEntry['segments']) { + return '/' + segments.map((s) => (s.splat ? `*${s.param}` : s.param ? `:${s.param}` : s.value)).join('/') +} + +/** Expand a *.crud.json seed into 6 REST routes backed by the in-memory store. */ +function crudEntries(file: string, baseSegments: RouteEntry['segments'], collection: string): RouteEntry[] { + store.seed(collection, file) + if (process.env.PERSIST === 'true') store.enablePersist(collection, file) + const idSeg = { value: '[id]', param: 'id' } + const mk = (method: HttpMethod, withId: boolean, op: CrudOp, status?: number): RouteEntry => { + const segments = withId ? [...baseSegments, idSeg] : baseSegments + return { file, method, status, segments, template: segTemplate(segments), kind: 'crud', crud: { collection, op } } + } + return [ + mk('get', false, 'list'), + mk('post', false, 'create', 201), + mk('get', true, 'get'), + mk('put', true, 'put'), + mk('patch', true, 'patch'), + mk('delete', true, 'delete', 204), + ] +} + +export function scan(routesDir: string): RouteEntry[] { + const entries: RouteEntry[] = [] + const walk = (dir: string) => { + let names: string[] + try { + names = readdirSync(dir) + } catch { + return + } + for (const n of names) { + // single leading underscore = private/partial (ignored); double = served (grouping) + if (n.startsWith('.') || (n.startsWith('_') && !n.startsWith('__'))) continue + const abs = join(dir, n) + if (statSync(abs).isDirectory()) { + walk(abs) + continue + } + const rel = relative(routesDir, abs) + if (CRUD_RE.test(n)) { + const base = n.replace(CRUD_RE, '') + const segs = toSegments(rel, base) + entries.push(...crudEntries(abs, segs, segs.map((s) => s.value).join('.') || base)) + continue + } + if (!DATA_EXT.test(n) && !MOD_EXT.test(n)) continue + const { base, method, status, kind } = parseFilename(n) + const segments = toSegments(rel, base) + entries.push({ file: abs, method, status, kind, segments, template: segTemplate(segments) }) + } + } + walk(routesDir) + const hasSplat = (e: RouteEntry) => (e.segments[e.segments.length - 1]?.splat ? 1 : 0) + entries.sort((a, b) => { + if (hasSplat(a) !== hasSplat(b)) return hasSplat(a) - hasSplat(b) // splat routes last + if (b.segments.length !== a.segments.length) return b.segments.length - a.segments.length + return a.segments.filter((s) => s.param).length - b.segments.filter((s) => s.param).length + }) + return entries +} + +export function match(entries: RouteEntry[], method: string, pathname: string): MatchResult | null { + const reqMethod = method.toLowerCase() + const reqSegs = pathname.split('/').filter(Boolean) + for (const entry of entries) { + if (entry.method !== reqMethod && !(reqMethod === 'head' && entry.method === 'get')) continue + const last = entry.segments[entry.segments.length - 1] + const hasSplat = last?.splat + if (hasSplat) { + if (reqSegs.length < entry.segments.length - 1) continue + } else if (entry.segments.length !== reqSegs.length) continue + + const params: Record = {} + let ok = true + for (let i = 0; i < entry.segments.length; i++) { + const seg = entry.segments[i] + if (seg.splat) { + params[seg.param!] = reqSegs.slice(i).map(decodeURIComponent).join('/') + break + } + if (seg.param) params[seg.param] = decodeURIComponent(reqSegs[i]) + else if (seg.value !== reqSegs[i]) { + ok = false + break + } + } + if (ok) return { entry, params } + } + return null +} + +export function watchRoutes(routesDir: string, onChange: () => void) { + let t: NodeJS.Timeout | null = null + try { + watch(routesDir, { recursive: true }, () => { + if (t) clearTimeout(t) + t = setTimeout(onChange, 120) + }) + } catch { + /* recursive watch unsupported — skip */ + } +} diff --git a/src/respond.ts b/src/respond.ts new file mode 100644 index 0000000..650c776 --- /dev/null +++ b/src/respond.ts @@ -0,0 +1,295 @@ +import { pathToFileURL } from 'node:url' +import type { Context } from 'hono' +import { streamSSE } from 'hono/streaming' +import type { MatchResult, CrudOp } from './registry.ts' +import { parseDataFile } from './parse.ts' +import { store } from './store.ts' +import { applyTemplate } from './template.ts' +import { nextSeqIndex, rateCheck } from './runtime.ts' +import { validateBody, type ValidateSchema } from './validate.ts' +import { proxyPass } from './proxy.ts' + +export interface MockContext { + params: Record + query: Record + headers: Record + method: string + body: unknown + faker: typeof import('@faker-js/faker').faker +} + +export type Delay = number | [number, number] + +export interface MockResponse { + status?: number + headers?: Record + delay?: Delay + body?: unknown + /** when set, the request is forwarded to this upstream instead */ + proxy?: string +} + +export function resolveDelay(d: Delay | undefined): number { + if (d === undefined) return 0 + if (Array.isArray(d)) { + const lo = Math.min(d[0], d[1]) + const hi = Math.max(d[0], d[1]) + return Math.round(lo + Math.random() * (hi - lo)) + } + return d +} + +const CONTROL = new Set([ + '__status', '__headers', '__delay', '__delayed', '__body', '__variants', + '__sequence', '__chaos', '__rateLimit', '__auth', '__validate', '__proxy', '__doc', +]) + +function stripControl(obj: Record): unknown { + if ('__body' in obj) return obj.__body + const out: Record = {} + for (const k of Object.keys(obj)) if (!CONTROL.has(k)) out[k] = obj[k] + return out +} + +// ── matchers for __variants ──────────────────────────────────────────────── +interface Variant { + when?: { query?: Record; headers?: Record; params?: Record; body?: Record } + status?: number + headers?: Record + delay?: Delay + body?: unknown +} + +function subset(want: Record | undefined, got: Record, ci = false): boolean { + if (!want) return true + const g = ci ? Object.fromEntries(Object.entries(got).map(([k, v]) => [k.toLowerCase(), v])) : got + for (const [k, v] of Object.entries(want)) { + const key = ci ? k.toLowerCase() : k + if (typeof v === 'object' && v !== null && typeof g[key] === 'object' && g[key] !== null) { + if (!subset(v as Record, g[key] as Record)) return false + } else if (String(g[key]) !== String(v)) return false + } + return true +} + +function matchWhen(w: Variant['when'], ctx: MockContext): boolean { + if (!w) return true + return ( + subset(w.query, ctx.query) && + subset(w.headers, ctx.headers, true) && + subset(w.params, ctx.params) && + subset(w.body, (ctx.body as Record) ?? {}) + ) +} + +// ── auth / chaos ─────────────────────────────────────────────────────────── +function checkAuth(spec: unknown, ctx: MockContext): MockResponse | null { + const auth = ctx.headers['authorization'] ?? '' + const token = auth.replace(/^Bearer\s+/i, '') + const want = typeof spec === 'object' && spec ? (spec as any).token : undefined + const ok = want ? token === want : token.length > 0 + return ok ? null : { status: 401, headers: { 'WWW-Authenticate': 'Bearer' }, body: { error: 'unauthorized' } } +} + +// ── the JSON route engine ────────────────────────────────────────────────── +function fromJson(file: string, ctx: MockContext): MockResponse { + const raw = parseDataFile(file) + if (Array.isArray(raw)) return { body: applyTemplate(raw, ctx) } + if (typeof raw !== 'object' || raw === null) return { body: raw } + const o = raw as Record + + // per-route proxy short-circuit + if (typeof o.__proxy === 'string') return { proxy: o.__proxy } + + // auth gate + if (o.__auth) { + const denied = checkAuth(o.__auth, ctx) + if (denied) return denied + } + + // rate limit + if (o.__rateLimit) { + const { max = 60, windowMs = 60_000 } = o.__rateLimit as { max?: number; windowMs?: number } + const r = rateCheck(file, max, windowMs, Date.now()) + if (!r.allowed) { + return { + status: 429, + headers: { 'Retry-After': String(Math.ceil(r.retryMs / 1000)), 'X-RateLimit-Limit': String(max) }, + body: { error: 'rate limited', retryMs: r.retryMs }, + } + } + } + + // per-route chaos + if (o.__chaos) { + const { rate = 0, status = 500, body } = o.__chaos as { rate?: number; status?: number; body?: unknown } + if (Math.random() < rate) return { status, body: body ?? { error: 'chaos', injected: true } } + } + + // body validation (write methods) + if (o.__validate && ['post', 'put', 'patch'].includes(ctx.method.toLowerCase())) { + const errors = validateBody(o.__validate as ValidateSchema, ctx.body) + if (errors.length) return { status: 422, body: { error: 'validation failed', errors } } + } + + // sequence: different response each call + if (Array.isArray(o.__sequence)) { + const seq = o.__sequence as any[] + const item = seq[nextSeqIndex(file, seq.length)] + const r: MockResponse = + item && typeof item === 'object' && ('body' in item || 'status' in item) + ? { status: item.status, headers: item.headers, delay: item.delay, body: item.body } + : { body: item } + r.body = applyTemplate(r.body, ctx) + return r + } + + // variants + if (Array.isArray(o.__variants)) { + const chosen = (o.__variants as Variant[]).find((v) => matchWhen(v.when, ctx)) + if (!chosen) return { status: 404, body: { error: 'no matching variant' } } + return { status: chosen.status, headers: chosen.headers, delay: chosen.delay, body: applyTemplate(chosen.body, ctx) } + } + + // plain body + control envelope + const rawDelay = o.__delay ?? o.__delayed + return { + status: typeof o.__status === 'number' ? o.__status : undefined, + headers: (o.__headers as Record) ?? undefined, + delay: typeof rawDelay === 'number' || Array.isArray(rawDelay) ? (rawDelay as Delay) : undefined, + body: applyTemplate(stripControl(o), ctx), + } +} + +async function fromModule(file: string, ctx: MockContext): Promise { + const url = pathToFileURL(file).href + `?t=${Date.now()}` + const mod = await import(url) + const handler = mod.default ?? mod.handler + if (typeof handler !== 'function') return { status: 500, body: { error: `module ${file} has no default export function` } } + const result = await handler(ctx) + if (result && typeof result === 'object' && ('body' in result || 'status' in result)) return result as MockResponse + return { body: result } +} + +// ── CRUD with pagination + sort ──────────────────────────────────────────── +function fromCrud(collection: string, op: CrudOp, ctx: MockContext): MockResponse { + const id = ctx.params.id + const q = ctx.query + switch (op) { + case 'list': { + let items = store.list(collection) + for (const [k, v] of Object.entries(q)) { + if (k.startsWith('_')) continue + items = items.filter((it) => String((it as Record)[k]) === v) + } + if (q._sort) { + const key = q._sort + const dir = q._order === 'desc' ? -1 : 1 + items = [...items].sort((a, b) => { + const av = (a as any)[key], bv = (b as any)[key] + return av < bv ? -dir : av > bv ? dir : 0 + }) + } + const total = items.length + if (q._start || q._end) { + items = items.slice(Number(q._start ?? 0), q._end ? Number(q._end) : undefined) + } else if (q._page || q._limit) { + const limit = Number(q._limit ?? 10) + const page = Number(q._page ?? 1) + items = items.slice((page - 1) * limit, (page - 1) * limit + limit) + } else if (q._limit) { + items = items.slice(0, Number(q._limit)) + } + return { body: items, headers: { 'X-Total-Count': String(total), 'Access-Control-Expose-Headers': 'X-Total-Count' } } + } + case 'get': { + const rec = store.get(collection, id) + return rec ? { body: rec } : { status: 404, body: { error: 'not found' } } + } + case 'create': + return { status: 201, body: store.create(collection, (ctx.body as Record) ?? {}) } + case 'put': + case 'patch': { + const rec = store.update(collection, id, (ctx.body as Record) ?? {}, op === 'patch') + return rec ? { body: rec } : { status: 404, body: { error: 'not found' } } + } + case 'delete': + return store.remove(collection, id) ? { status: 204, body: null } : { status: 404, body: { error: 'not found' } } + } +} + +// ── SSE streaming ────────────────────────────────────────────────────────── +function fromSse(c: Context, file: string): Response { + let cfg: any + try { + cfg = parseDataFile(file) + } catch { + return c.json({ error: 'invalid sse file' }, 500) + } + const events: any[] = Array.isArray(cfg) ? cfg : cfg.events ?? [] + const loop = !Array.isArray(cfg) && !!cfg.loop + return streamSSE(c, async (stream) => { + let round = 0 + do { + for (const ev of events) { + if (stream.aborted) return + if (ev.delay) await stream.sleep(ev.delay) + await stream.writeSSE({ + data: typeof ev.data === 'string' ? ev.data : JSON.stringify(ev.data ?? ev), + event: ev.event, + id: ev.id != null ? String(ev.id) : undefined, + }) + } + round++ + } while (loop && !stream.aborted && round < 10_000) + }) +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +export async function respond( + c: Context, + m: MatchResult, + opts: { defaultDelay: Delay; faker: MockContext['faker']; routesDir: string }, +): Promise { + const { entry, params } = m + + let parsedBody: unknown + if (['post', 'put', 'patch', 'delete'].includes(entry.method)) { + try { + parsedBody = await c.req.json() + } catch { + parsedBody = undefined + } + } + + const ctx: MockContext = { + params, + query: c.req.query(), + headers: Object.fromEntries(c.req.raw.headers), + method: c.req.method, + body: parsedBody, + faker: opts.faker, + } + + if (entry.kind === 'sse') return fromSse(c, entry.file) + + const res = + entry.kind === 'crud' + ? fromCrud(entry.crud!.collection, entry.crud!.op, ctx) + : entry.kind === 'json' + ? fromJson(entry.file, ctx) + : await fromModule(entry.file, ctx) + + if (res.proxy) return proxyPass(c, { target: res.proxy, record: false, routesDir: opts.routesDir }) + + const status = res.status ?? entry.status ?? 200 + const delay = resolveDelay(res.delay ?? opts.defaultDelay) + if (delay > 0) await sleep(delay) + + if (res.headers) for (const [k, v] of Object.entries(res.headers)) c.header(k, v) + c.header('X-Mock-File', entry.file) + + if (status === 204 || res.body === null || res.body === undefined) return c.body(null, status as any) + return c.json(res.body as any, status as any) +} diff --git a/src/runtime.ts b/src/runtime.ts new file mode 100644 index 0000000..498122c --- /dev/null +++ b/src/runtime.ts @@ -0,0 +1,26 @@ +// Per-route runtime state: sequence cursors + rate-limit windows. +// Keyed by route file; reset on server restart. + +const seqCursor = new Map() + +/** Return the next index in a sequence of length n, advancing the cursor. */ +export function nextSeqIndex(key: string, n: number): number { + const i = seqCursor.get(key) ?? 0 + seqCursor.set(key, i + 1) + return i % n +} + +const rlHits = new Map() + +/** Sliding-window rate limit. Returns remaining + whether this call is allowed. */ +export function rateCheck(key: string, max: number, windowMs: number, now: number) { + const hits = (rlHits.get(key) ?? []).filter((t) => now - t < windowMs) + if (hits.length >= max) { + rlHits.set(key, hits) + const retryMs = windowMs - (now - hits[0]) + return { allowed: false, remaining: 0, retryMs } + } + hits.push(now) + rlHits.set(key, hits) + return { allowed: true, remaining: max - hits.length, retryMs: 0 } +} diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000..bd97245 --- /dev/null +++ b/src/store.ts @@ -0,0 +1,93 @@ +import { writeFileSync } from 'node:fs' +import { parseDataFile } from './parse.ts' + +type Record_ = Record + +/** In-memory collections for stateful CRUD mocks. Seeded from .crud.json files. */ +class Store { + private cols = new Map>() + private counters = new Map() + private seeded = new Set() + private persistFile = new Map() + private saveTimers = new Map() + + /** Enable disk write-back for a collection (PERSIST=true). */ + enablePersist(collection: string, file: string) { + this.persistFile.set(collection, file) + } + + private save(c: string) { + const file = this.persistFile.get(c) + if (!file) return + const prev = this.saveTimers.get(c) + if (prev) clearTimeout(prev) + this.saveTimers.set( + c, + setTimeout(() => { + try { + writeFileSync(file, JSON.stringify(this.list(c), null, 2) + '\n') + } catch (e) { + console.error(`persist failed for ${c}:`, e) + } + }, 150), + ) + } + + /** Seed a collection once from its source file (idempotent across hot reloads). */ + seed(collection: string, file: string) { + if (this.seeded.has(collection)) return + this.seeded.add(collection) + const col = new Map() + let max = 0 + try { + const data = parseDataFile(file) + const arr = Array.isArray(data) ? data : [] + for (const item of arr as Record_[]) { + const id = String(item.id ?? ++max) + if (Number(id) > max) max = Number(id) + col.set(id, { ...item, id: coerceId(item.id ?? id) }) + } + } catch { + /* empty / invalid seed → empty collection */ + } + this.cols.set(collection, col) + this.counters.set(collection, max) + } + + list(c: string) { + return [...(this.cols.get(c)?.values() ?? [])] + } + get(c: string, id: string) { + return this.cols.get(c)?.get(id) ?? null + } + create(c: string, body: Record_) { + const col = this.cols.get(c)! + const next = (this.counters.get(c) ?? 0) + 1 + this.counters.set(c, next) + const id = body.id != null ? coerceId(body.id) : next + const rec = { ...body, id } + col.set(String(id), rec) + this.save(c) + return rec + } + update(c: string, id: string, body: Record_, merge: boolean) { + const col = this.cols.get(c) + if (!col?.has(id)) return null + const rec = merge ? { ...col.get(id), ...body, id: coerceId(id) } : { ...body, id: coerceId(id) } + col.set(id, rec) + this.save(c) + return rec + } + remove(c: string, id: string) { + const ok = this.cols.get(c)?.delete(id) ?? false + if (ok) this.save(c) + return ok + } +} + +function coerceId(v: unknown) { + const n = Number(v) + return Number.isInteger(n) && String(n) === String(v) ? n : v +} + +export const store = new Store() diff --git a/src/template.ts b/src/template.ts new file mode 100644 index 0000000..b0104ce --- /dev/null +++ b/src/template.ts @@ -0,0 +1,58 @@ +import type { MockContext } from './respond.ts' + +/** + * Replace `{{ path }}` placeholders inside a JSON value. + * {{params.id}} {{query.q}} {{headers.x-foo}} + * {{faker.person.fullName}} {{faker.number.int}} (zero-arg faker methods) + * If a string is exactly one placeholder, the resolved (typed) value is used — + * so {{faker.number.int}} yields a number, not "123". + */ +const RE = /\{\{\s*([\w.$-]+)\s*\}\}/g + +function resolve(path: string, ctx: MockContext): unknown { + const parts = path.split('.') + let cur: any = + parts[0] === 'faker' + ? ctx.faker + : parts[0] === 'params' + ? ctx.params + : parts[0] === 'query' + ? ctx.query + : parts[0] === 'headers' + ? ctx.headers + : undefined + if (cur === undefined) return undefined + for (let i = 1; i < parts.length; i++) { + cur = cur?.[parts[i]] + if (cur === undefined) return undefined + } + return typeof cur === 'function' ? cur.call(parts[0] === 'faker' ? undefined : cur) : cur +} + +export function hasTemplate(v: unknown): boolean { + if (typeof v === 'string') return RE.test(v) + if (Array.isArray(v)) return v.some(hasTemplate) + if (v && typeof v === 'object') return Object.values(v).some(hasTemplate) + return false +} + +export function applyTemplate(value: unknown, ctx: MockContext): unknown { + if (typeof value === 'string') { + const whole = value.match(/^\{\{\s*([\w.$-]+)\s*\}\}$/) + if (whole) { + const r = resolve(whole[1], ctx) + return r === undefined ? value : r + } + return value.replace(RE, (m, p) => { + const r = resolve(p, ctx) + return r === undefined ? m : String(r) + }) + } + if (Array.isArray(value)) return value.map((v) => applyTemplate(v, ctx)) + if (value && typeof value === 'object') { + const out: Record = {} + for (const [k, v] of Object.entries(value)) out[k] = applyTemplate(v, ctx) + return out + } + return value +} diff --git a/src/validate.ts b/src/validate.ts new file mode 100644 index 0000000..c523e90 --- /dev/null +++ b/src/validate.ts @@ -0,0 +1,38 @@ +/** + * Tiny body validator. Schema maps field -> type spec: + * { name: 'string', age: 'number', email: 'string?', tags: 'array', meta: 'object' } + * A trailing `?` makes the field optional. Returns a list of error messages + * (empty = valid). + */ +export type ValidateSchema = Record + +const checkType = (v: unknown, t: string): boolean => { + switch (t) { + case 'string': return typeof v === 'string' + case 'number': return typeof v === 'number' + case 'boolean': return typeof v === 'boolean' + case 'array': return Array.isArray(v) + case 'object': return v !== null && typeof v === 'object' && !Array.isArray(v) + case 'any': return true + default: return true + } +} + +export function validateBody(schema: ValidateSchema, body: unknown): string[] { + const errors: string[] = [] + const obj = (body ?? {}) as Record + if (body === null || typeof body !== 'object' || Array.isArray(body)) { + return ['body must be a JSON object'] + } + for (const [field, specRaw] of Object.entries(schema)) { + const optional = specRaw.endsWith('?') + const type = optional ? specRaw.slice(0, -1) : specRaw + const present = field in obj && obj[field] !== undefined && obj[field] !== null + if (!present) { + if (!optional) errors.push(`${field} is required`) + continue + } + if (!checkType(obj[field], type)) errors.push(`${field} must be ${type}`) + } + return errors +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f1bb2d1 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "verbatimModuleSyntax": false, + "types": ["node"], + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["src/**/*.ts", "routes/**/*.ts"] +}