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,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}`)
|
||||
Reference in New Issue
Block a user