feat: mockly-hono — file-based mock API server (Hono)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
# __test — example routes
|
||||
|
||||
One example per feature. The `__` (double underscore) prefix is **served**; a
|
||||
single `_` prefix (like `_lib.ts`) is ignored by the scanner.
|
||||
|
||||
| Route | File | Feature |
|
||||
|---|---|---|
|
||||
| `GET /__test/static` | `static.json` | plain JSON body |
|
||||
| `POST /__test/created` → 201 | `created.post.201.json` | status + `__headers` + `__delay` + `__body` |
|
||||
| `GET /__test/slow` | `slow.json` | `__delay: 1500` (fixed) |
|
||||
| `GET /__test/jitter` | `jitter.json` | `__delay: [200, 1200]` (random per request) |
|
||||
| `GET /__test/teapot` → 418 | `teapot.418.json` | status from filename |
|
||||
| `GET /__test/profile` | `profile.json5` | JSON5 + `__variants` (try `?plan=pro`, header `X-Admin: 1`) |
|
||||
| `GET/POST/… /__test/products[/:id]` | `products.crud.json` | stateful CRUD (6 routes) |
|
||||
| `GET /__test/users/:id` | `users/[id].get.ts` | dynamic handler + faker + param (try `?verbose=1`) |
|
||||
| `POST /__test/echo` | `echo.post.ts` | echo request |
|
||||
| `GET /__test/quote` | `quote.get.ts` | handler importing a `_lib.ts` partial |
|
||||
| `GET /__test/template` | `template.json` | `{{faker}}` / `{{query}}` / `{{headers}}` templating |
|
||||
| `GET /__test/tpl/faker` | `tpl/faker.json` | many faker domains, nested |
|
||||
| `GET /__test/tpl/user/:id` | `tpl/user/[id].json` | `{{params.id}}` + faker, no `.ts` needed |
|
||||
| `GET /__test/tpl/echo` | `tpl/echo.json` | `{{query}}` + `{{headers}}`, unresolved stays literal |
|
||||
| `GET /__test/tpl/typed` | `tpl/typed.json` | typed (number/bool) vs string interpolation |
|
||||
| `GET /__test/tpl/list` | `tpl/list.json` | array — each element resolves independently |
|
||||
| `GET /__test/flaky` | `flaky.json` | `__sequence` (202, 202, 200 …) |
|
||||
| `GET /__test/roulette` | `roulette.json` | `__chaos` 50% → 503 |
|
||||
| `GET /__test/limited` | `limited.json` | `__rateLimit` 3/10s → 429 |
|
||||
| `GET /__test/secret` | `secret.json` | `__auth` Bearer `s3cret` → 401 |
|
||||
| `POST /__test/signup` | `signup.post.json` | `__validate` → 422 |
|
||||
| `GET /__test/proxied` | `proxied.json` | `__proxy` to a real upstream |
|
||||
| `GET /__test/files/*` | `files/[...path].json` | splat route |
|
||||
| `GET /__test/ticker` | `ticker.sse.json` | SSE stream |
|
||||
| — | `_lib.ts` | shared data (ignored as a route) |
|
||||
|
||||
Scenario overlay: `routes/_scenarios/broken/__test/static.503.json` — try
|
||||
`curl -H 'X-Scenario: broken' localhost:4100/__test/static` → 503.
|
||||
|
||||
Quick try:
|
||||
|
||||
```sh
|
||||
curl localhost:4100/__test/static
|
||||
curl -w '\n%{time_total}s\n' localhost:4100/__test/slow
|
||||
curl "localhost:4100/__test/profile?plan=pro"
|
||||
curl localhost:4100/__test/products
|
||||
curl -X POST localhost:4100/__test/products -H 'content-type: application/json' -d '{"name":"New","price":1}'
|
||||
curl "localhost:4100/__test/users/5?verbose=1"
|
||||
curl localhost:4100/__test/quote
|
||||
curl "localhost:4100/__test/template?q=hi"
|
||||
for i in 1 2 3; do curl -s localhost:4100/__test/flaky; echo; done # sequence
|
||||
curl -i localhost:4100/__test/secret # 401
|
||||
curl -i -H 'authorization: Bearer s3cret' localhost:4100/__test/secret
|
||||
curl -X POST localhost:4100/__test/signup -d '{"name":"x"}' -H 'content-type: application/json' # 422
|
||||
curl "localhost:4100/__test/products?_sort=price&_order=desc&_limit=2"
|
||||
curl localhost:4100/__test/files/a/b/c.txt # splat
|
||||
curl -N localhost:4100/__test/ticker # SSE
|
||||
curl -H 'X-Scenario: broken' localhost:4100/__test/static # scenario
|
||||
```
|
||||
|
||||
All visible in the visual docs at `/__docs`.
|
||||
@@ -0,0 +1,6 @@
|
||||
// single-underscore file = NOT a route (ignored by scanner) — shared data/helpers
|
||||
export const QUOTES = [
|
||||
'Mock fast, ship faster.',
|
||||
'A 404 a day keeps the backend away.',
|
||||
'It works on my mock.',
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"__delay": 250,
|
||||
"__headers": { "Location": "/__test/created/99", "X-Demo": "control-keys" },
|
||||
"__body": { "id": 99, "created": true }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { MockContext } from '../../src/respond.ts'
|
||||
|
||||
// feature: echo request — try:
|
||||
// curl -X POST localhost:4100/__test/echo -H 'content-type: application/json' -d '{"hi":1}'
|
||||
export default function ({ body, query, headers, method }: MockContext) {
|
||||
return { method, received: body, query, contentType: headers['content-type'] ?? null }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"feature": "splat route — catches any depth",
|
||||
"captured": "{{params.path}}"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"__sequence": [
|
||||
{ "status": 202, "body": { "state": "pending" } },
|
||||
{ "status": 202, "body": { "state": "pending" } },
|
||||
{ "status": 200, "body": { "state": "ready", "result": 42 } }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"__delay": [200, 1200],
|
||||
"feature": "random delay jitter",
|
||||
"note": "each request waits a random 200-1200ms — repeat: curl -w '%{time_total}s' localhost:4100/__test/jitter"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"__rateLimit": { "max": 3, "windowMs": 10000 },
|
||||
"feature": "rate limit (3 per 10s, then 429)",
|
||||
"ok": true
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
{ "id": 1, "name": "Widget", "price": 9.99, "stock": 12 },
|
||||
{ "id": 2, "name": "Gadget", "price": 19.99, "stock": 0 },
|
||||
{ "id": 3, "name": "Gizmo", "price": 4.5, "stock": 99 }
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
// feature: json5 (comments, trailing commas) + __variants
|
||||
// try: /__test/profile · ?plan=pro · header X-Admin: 1
|
||||
__variants: [
|
||||
{
|
||||
when: { query: { plan: 'pro' } },
|
||||
delay: 200,
|
||||
body: { plan: 'pro', seats: 25, features: ['sso', 'audit'] },
|
||||
},
|
||||
{
|
||||
when: { headers: { 'x-admin': '1' } },
|
||||
body: { plan: 'enterprise', seats: 999, features: ['sso', 'audit', 'scim'] },
|
||||
},
|
||||
{
|
||||
// fallback (no `when`)
|
||||
body: { plan: 'free', seats: 1, features: [] },
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"__proxy": "https://api.energy-charts.info",
|
||||
"note": "this route forwards to the upstream (same path+query); response tagged X-Mock-Proxy"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { MockContext } from '../../src/respond.ts'
|
||||
import { QUOTES } from './_lib.ts'
|
||||
|
||||
// feature: handler importing a _partial (shared data) + random pick
|
||||
export default function ({ faker }: MockContext) {
|
||||
return { quote: faker.helpers.arrayElement(QUOTES), total: QUOTES.length }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"__chaos": { "rate": 0.5, "status": 503, "body": { "error": "unlucky" } },
|
||||
"feature": "per-route chaos (50% -> 503)",
|
||||
"ok": true
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"__auth": { "token": "s3cret" },
|
||||
"feature": "auth gate",
|
||||
"data": "top secret — needs Authorization: Bearer s3cret"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"__validate": { "name": "string", "age": "number", "email": "string?" },
|
||||
"__status": 201,
|
||||
"__body": { "ok": true, "note": "POST valid body -> 201, else 422 with errors" }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"__delay": 1500,
|
||||
"feature": "delay",
|
||||
"note": "responds after 1.5s — time it: curl -w '%{time_total}s' localhost:4100/__test/slow"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"__doc": {
|
||||
"summary": "Static JSON example",
|
||||
"description": "Returns the file body **as-is** with status 200.\n\nThe `__doc` key adds this text to the visual docs and is stripped from the response."
|
||||
},
|
||||
"feature": "plain json",
|
||||
"note": "file body returned as-is, status 200",
|
||||
"items": [1, 2, 3]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"__body": { "feature": "status from filename", "error": "I'm a teapot" }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"feature": "templating in plain json",
|
||||
"name": "{{faker.person.fullName}}",
|
||||
"email": "{{faker.internet.email}}",
|
||||
"luckyNumber": "{{faker.number.int}}",
|
||||
"youSaid": "q = {{query.q}}",
|
||||
"ua": "{{headers.user-agent}}"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"loop": false,
|
||||
"events": [
|
||||
{ "event": "tick", "id": 1, "data": { "n": 1 }, "delay": 150 },
|
||||
{ "event": "tick", "id": 2, "data": { "n": 2 }, "delay": 150 },
|
||||
{ "event": "done", "id": 3, "data": { "n": 3, "final": true }, "delay": 150 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"feature": "request data — query + headers",
|
||||
"search": "you searched: {{query.q}}",
|
||||
"page": "{{query.page}}",
|
||||
"userAgent": "{{headers.user-agent}}",
|
||||
"tier": "{{headers.x-tier}}",
|
||||
"unresolved": "{{query.missing}} stays literal if absent"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"title": "faker fields (zero-arg methods, fresh each request)",
|
||||
"person": {
|
||||
"name": "{{faker.person.fullName}}",
|
||||
"firstName": "{{faker.person.firstName}}",
|
||||
"job": "{{faker.person.jobTitle}}"
|
||||
},
|
||||
"internet": {
|
||||
"email": "{{faker.internet.email}}",
|
||||
"username": "{{faker.internet.username}}",
|
||||
"url": "{{faker.internet.url}}"
|
||||
},
|
||||
"location": {
|
||||
"city": "{{faker.location.city}}",
|
||||
"country": "{{faker.location.country}}",
|
||||
"zip": "{{faker.location.zipCode}}"
|
||||
},
|
||||
"ids": {
|
||||
"uuid": "{{faker.string.uuid}}",
|
||||
"int": "{{faker.number.int}}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"feature": "array of templated objects — each element resolves independently",
|
||||
"users": [
|
||||
{ "id": 1, "name": "{{faker.person.fullName}}", "city": "{{faker.location.city}}" },
|
||||
{ "id": 2, "name": "{{faker.person.fullName}}", "city": "{{faker.location.city}}" },
|
||||
{ "id": 3, "name": "{{faker.person.fullName}}", "city": "{{faker.location.city}}" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"feature": "typed vs string",
|
||||
"wholePlaceholder_number": "{{faker.number.int}}",
|
||||
"wholePlaceholder_bool": "{{faker.datatype.boolean}}",
|
||||
"embedded_becomes_string": "lucky: {{faker.number.int}}",
|
||||
"mixed": "{{params.id}}-{{faker.string.alpha}}"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"feature": "path param + faker in plain json (no .ts handler)",
|
||||
"id": "{{params.id}}",
|
||||
"profileUrl": "/users/{{params.id}}",
|
||||
"name": "{{faker.person.fullName}}",
|
||||
"avatar": "{{faker.image.avatar}}"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { MockContext } from '../../../src/respond.ts'
|
||||
|
||||
// shown in the visual docs (/__docs) — handler-level description
|
||||
export const doc = {
|
||||
summary: 'Synthetic user',
|
||||
description: 'Deterministic faker user keyed off `:id`. Add `?verbose=1` for a bio.',
|
||||
}
|
||||
|
||||
// feature: dynamic handler + faker + path param + query
|
||||
// try: /__test/users/5 · /__test/users/5?verbose=1
|
||||
export default function ({ params, query, faker }: MockContext) {
|
||||
faker.seed(Number(params.id) || 1) // deterministic per id
|
||||
return {
|
||||
id: params.id,
|
||||
name: faker.person.fullName(),
|
||||
email: faker.internet.email(),
|
||||
avatar: faker.image.avatar(),
|
||||
bio: query.verbose === '1' ? faker.lorem.paragraph() : undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"__body": { "error": "service unavailable", "scenario": "broken" }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
// group (tag) descriptions shown at the top of each section in /__docs
|
||||
// key = first path segment
|
||||
__test: 'Worked examples — one route per mockly feature.',
|
||||
todos: 'Demo CRUD resource (in-memory).',
|
||||
users: 'User endpoints.',
|
||||
pages: 'Static page content.',
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
// json5: comments + trailing commas + unquoted keys
|
||||
// __variants picks the first whose `when` matches the request
|
||||
__variants: [
|
||||
{
|
||||
when: { query: { role: 'admin' } },
|
||||
body: { role: 'admin', canDelete: true, seats: 999 },
|
||||
},
|
||||
{
|
||||
when: { headers: { 'x-tier': 'pro' } },
|
||||
body: { role: 'user', canDelete: false, seats: 25 },
|
||||
},
|
||||
{
|
||||
// fallback (no `when`)
|
||||
body: { role: 'user', canDelete: false, seats: 1 },
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { MockContext } from '../src/respond.ts'
|
||||
|
||||
// Echo back whatever was POSTed — handy for testing client request shaping.
|
||||
export default function ({ body, query, headers }: MockContext) {
|
||||
return { received: body, query, contentType: headers['content-type'] ?? null }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"__body": { "error": "not found", "code": "DEMO_404" }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Homepage",
|
||||
"blocks": ["hero", "features", "footer"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
[
|
||||
{ "id": 1, "title": "wire up mockly", "done": true },
|
||||
{ "id": 2, "title": "write tests", "done": false }
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"__delay": 300,
|
||||
"__headers": { "Location": "/users/42" },
|
||||
"__body": { "id": 42, "created": true }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { MockContext } from '../../src/respond.ts'
|
||||
|
||||
// Dynamic mock: GET /users/:id -> synthetic user keyed off the id.
|
||||
export default function ({ params, query, faker }: MockContext) {
|
||||
faker.seed(Number(params.id) || 1)
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: params.id,
|
||||
name: faker.person.fullName(),
|
||||
email: faker.internet.email(),
|
||||
city: faker.location.city(),
|
||||
verbose: query.verbose === '1' ? faker.lorem.paragraph() : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user