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:
Peter Meier
2026-06-23 12:44:31 +02:00
commit b122de518c
53 changed files with 2563 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
/**
* Generate mock route files from an OpenAPI 3 spec (json or yaml).
*
* npm run openapi -- path/to/openapi.yaml [outDir]
*
* For each path × method it writes a route file whose body is the response
* example (or a synthesized one from the schema). Non-200 success codes land
* in the filename (e.g. users.post.201.json).
*/
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import YAML from 'yaml'
const __dirname = dirname(fileURLToPath(import.meta.url))
const specPath = process.argv[2]
const outDir = process.argv[3] ?? join(__dirname, '..', 'routes')
if (!specPath) {
console.error('usage: npm run openapi -- <spec.yaml|json> [outDir]')
process.exit(1)
}
const text = readFileSync(specPath, 'utf8')
const spec: any = specPath.endsWith('.json') ? JSON.parse(text) : YAML.parse(text)
function resolveRef(ref: string): any {
// #/components/schemas/Foo
const parts = ref.replace(/^#\//, '').split('/')
return parts.reduce((o, k) => o?.[k], spec)
}
function example(schema: any, depth = 0): any {
if (!schema || depth > 6) return null
if (schema.$ref) return example(resolveRef(schema.$ref), depth + 1)
if (schema.example !== undefined) return schema.example
if (schema.default !== undefined) return schema.default
if (schema.enum) return schema.enum[0]
if (schema.allOf) return Object.assign({}, ...schema.allOf.map((s: any) => example(s, depth + 1)))
if (schema.oneOf || schema.anyOf) return example((schema.oneOf ?? schema.anyOf)[0], depth + 1)
switch (schema.type) {
case 'object': {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(schema.properties ?? {})) out[k] = example(v, depth + 1)
return out
}
case 'array':
return [example(schema.items, depth + 1)]
case 'integer':
case 'number':
return schema.format === 'float' ? 1.5 : 1
case 'boolean':
return true
case 'string':
if (schema.format === 'date-time') return '2026-01-01T00:00:00Z'
if (schema.format === 'email') return 'user@example.com'
if (schema.format === 'uuid') return '00000000-0000-0000-0000-000000000000'
return 'string'
default:
return schema.properties ? example({ ...schema, type: 'object' }, depth) : null
}
}
function pickResponse(op: any): { status: number; body: unknown } | null {
const responses = op.responses ?? {}
const code = Object.keys(responses).find((c) => /^2\d\d$/.test(c)) ?? Object.keys(responses)[0]
if (!code) return null
const r = responses[code]
const json = r?.content?.['application/json']
let body: unknown = {}
if (json?.example !== undefined) body = json.example
else if (json?.examples) body = (Object.values(json.examples)[0] as any)?.value ?? {}
else if (json?.schema) body = example(json.schema)
return { status: Number(code) || 200, body }
}
function fileFor(path: string, method: string, status: number): string {
// /users/{id} -> users/[id]
const segs = path.split('/').filter(Boolean).map((s) => s.replace(/^\{(.+)\}$/, '[$1]'))
const base = segs.length ? segs.join('/') : 'index'
const parts = [base]
if (method !== 'get') parts.push(method)
if (status !== 200 && status !== 0) parts.push(String(status))
return join(outDir, parts.join('.') + '.json')
}
let count = 0
for (const [path, item] of Object.entries<any>(spec.paths ?? {})) {
for (const method of ['get', 'post', 'put', 'patch', 'delete']) {
const op = item[method]
if (!op) continue
const picked = pickResponse(op)
if (!picked) continue
const file = fileFor(path, method, picked.status)
mkdirSync(dirname(file), { recursive: true })
writeFileSync(file, JSON.stringify(picked.body, null, 2) + '\n')
count++
console.log(`+ ${method.toUpperCase().padEnd(6)} ${path} -> ${file.replace(outDir + '/', '')}`)
}
}
console.log(`\n${count} route(s) generated into ${outDir}`)