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:
+162
@@ -0,0 +1,162 @@
|
||||
import { serve } from '@hono/node-server'
|
||||
import { Hono } from 'hono'
|
||||
import { cors } from 'hono/cors'
|
||||
import { compress } from 'hono/compress'
|
||||
import { logger } from 'hono/logger'
|
||||
import { faker } from '@faker-js/faker'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { readdirSync, statSync } from 'node:fs'
|
||||
import { scan, match, watchRoutes, type RouteEntry } from './registry.ts'
|
||||
import { respond } from './respond.ts'
|
||||
import { proxyPass } from './proxy.ts'
|
||||
import { buildOpenApi } from './openapi-gen.ts'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const ROUTES_DIR = process.env.ROUTES_DIR
|
||||
? process.env.ROUTES_DIR
|
||||
: join(__dirname, '..', 'routes')
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 4100)
|
||||
// REQUEST_DELAY: "500" fixed, or "200-800" random range per request
|
||||
const DEFAULT_DELAY: number | [number, number] = (() => {
|
||||
const raw = process.env.REQUEST_DELAY ?? '0'
|
||||
const m = raw.match(/^(\d+)-(\d+)$/)
|
||||
return m ? [Number(m[1]), Number(m[2])] : Number(raw) || 0
|
||||
})()
|
||||
const COMPRESS = (process.env.COMPRESS ?? 'true') === 'true'
|
||||
const CORS_ORIGIN = process.env.CORS_ORIGIN ?? '*'
|
||||
// chaos: inject random failures. CHAOS_RATE=0.1 => 10% of requests fail.
|
||||
const CHAOS_RATE = Number(process.env.CHAOS_RATE ?? 0)
|
||||
const CHAOS_STATUS = Number(process.env.CHAOS_STATUS ?? 500)
|
||||
const PROXY_TARGET = process.env.PROXY_TARGET ?? ''
|
||||
const RECORD = (process.env.RECORD ?? 'false') === 'true'
|
||||
|
||||
const METHOD_COLORS: Record<string, string> = {
|
||||
GET: '\x1b[32m', POST: '\x1b[33m', PUT: '\x1b[34m', PATCH: '\x1b[35m', DELETE: '\x1b[31m',
|
||||
}
|
||||
const DIM = '\x1b[2m', RESET = '\x1b[0m'
|
||||
|
||||
function printRoutes(list: RouteEntry[]) {
|
||||
const sorted = [...list].sort((a, b) => a.template.localeCompare(b.template) || a.method.localeCompare(b.method))
|
||||
console.log(`routes (${sorted.length}):`)
|
||||
for (const r of sorted) {
|
||||
const m = r.method.toUpperCase()
|
||||
const col = METHOD_COLORS[m] ?? ''
|
||||
const tag = r.status ? ` ${DIM}→${r.status}${RESET}` : ''
|
||||
const kind = r.kind !== 'json' ? ` ${DIM}[${r.kind}]${RESET}` : ''
|
||||
console.log(` ${col}${m.padEnd(6)}${RESET} ${r.template}${tag}${kind}`)
|
||||
}
|
||||
}
|
||||
|
||||
// scenario overlays: routes/_scenarios/<name>/** matched first when X-Scenario header is set
|
||||
const SCENARIOS_DIR = join(ROUTES_DIR, '_scenarios')
|
||||
function scanScenarios(): Map<string, RouteEntry[]> {
|
||||
const map = new Map<string, RouteEntry[]>()
|
||||
try {
|
||||
for (const n of readdirSync(SCENARIOS_DIR)) {
|
||||
const dir = join(SCENARIOS_DIR, n)
|
||||
if (statSync(dir).isDirectory()) map.set(n, scan(dir))
|
||||
}
|
||||
} catch {
|
||||
/* no _scenarios dir */
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
let routes: RouteEntry[] = scan(ROUTES_DIR)
|
||||
let scenarios = scanScenarios()
|
||||
const rescan = () => {
|
||||
routes = scan(ROUTES_DIR)
|
||||
scenarios = scanScenarios()
|
||||
console.log(`\n↻ routes reloaded`)
|
||||
printRoutes(routes)
|
||||
if (scenarios.size) console.log(`scenarios: ${[...scenarios.keys()].join(', ')}`)
|
||||
}
|
||||
watchRoutes(ROUTES_DIR, rescan)
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
app.use('*', logger())
|
||||
app.use('*', cors({ origin: CORS_ORIGIN }))
|
||||
if (COMPRESS) app.use('*', compress())
|
||||
|
||||
// introspection: list every mock route
|
||||
app.get('/__routes', (c) =>
|
||||
c.json({
|
||||
count: routes.length,
|
||||
routes: routes
|
||||
.map((r) => ({ method: r.method.toUpperCase(), path: r.template, status: r.status, kind: r.kind }))
|
||||
.sort((a, b) => a.path.localeCompare(b.path)),
|
||||
}),
|
||||
)
|
||||
app.get('/__health', (c) => c.json({ ok: true, routes: routes.length }))
|
||||
|
||||
// OpenAPI spec generated live from the route files
|
||||
app.get('/__openapi.json', async (c) => c.json((await buildOpenApi(routes, ROUTES_DIR)) as any))
|
||||
|
||||
// visual API docs (Scalar via CDN, points at the live spec)
|
||||
app.get('/__docs', (c) =>
|
||||
c.html(`<!doctype html><html><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>mockly-hono · API docs</title></head><body>
|
||||
<script id="api-reference" data-url="/__openapi.json"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
|
||||
</body></html>`),
|
||||
)
|
||||
|
||||
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 })
|
||||
@@ -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<string, unknown>
|
||||
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<string, unknown> = {}
|
||||
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<Doc> {
|
||||
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<string, string> {
|
||||
for (const name of ['_tags.json5', '_tags.json']) {
|
||||
try {
|
||||
return parseDataFile(join(routesDir, name)) as Record<string, string>
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
export async function buildOpenApi(routes: RouteEntry[], routesDir = '', title = 'mockly-hono') {
|
||||
const paths: Record<string, Record<string, unknown>> = {}
|
||||
const usedTags = new Set<string>()
|
||||
|
||||
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<string, unknown> = {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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'))
|
||||
}
|
||||
@@ -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<Response> {
|
||||
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
|
||||
}
|
||||
+164
@@ -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<HttpMethod>(['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<string, string>
|
||||
}
|
||||
|
||||
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<string, string> = {}
|
||||
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 */
|
||||
}
|
||||
}
|
||||
+295
@@ -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<string, string>
|
||||
query: Record<string, string>
|
||||
headers: Record<string, string>
|
||||
method: string
|
||||
body: unknown
|
||||
faker: typeof import('@faker-js/faker').faker
|
||||
}
|
||||
|
||||
export type Delay = number | [number, number]
|
||||
|
||||
export interface MockResponse {
|
||||
status?: number
|
||||
headers?: Record<string, string>
|
||||
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<string, unknown>): unknown {
|
||||
if ('__body' in obj) return obj.__body
|
||||
const out: Record<string, unknown> = {}
|
||||
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<string, string>; headers?: Record<string, string>; params?: Record<string, string>; body?: Record<string, unknown> }
|
||||
status?: number
|
||||
headers?: Record<string, string>
|
||||
delay?: Delay
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
function subset(want: Record<string, unknown> | undefined, got: Record<string, unknown>, 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<string, unknown>, g[key] as Record<string, unknown>)) 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<string, unknown>) ?? {})
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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<string, unknown>
|
||||
|
||||
// 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<string, string>) ?? 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<MockResponse> {
|
||||
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<string, unknown>)[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<string, unknown>) ?? {}) }
|
||||
case 'put':
|
||||
case 'patch': {
|
||||
const rec = store.update(collection, id, (ctx.body as Record<string, unknown>) ?? {}, 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<Response> {
|
||||
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)
|
||||
}
|
||||
@@ -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<string, number>()
|
||||
|
||||
/** 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<string, number[]>()
|
||||
|
||||
/** 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 }
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { parseDataFile } from './parse.ts'
|
||||
|
||||
type Record_ = Record<string, unknown>
|
||||
|
||||
/** In-memory collections for stateful CRUD mocks. Seeded from .crud.json files. */
|
||||
class Store {
|
||||
private cols = new Map<string, Map<string, Record_>>()
|
||||
private counters = new Map<string, number>()
|
||||
private seeded = new Set<string>()
|
||||
private persistFile = new Map<string, string>()
|
||||
private saveTimers = new Map<string, NodeJS.Timeout>()
|
||||
|
||||
/** 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<string, Record_>()
|
||||
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()
|
||||
@@ -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<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(value)) out[k] = applyTemplate(v, ctx)
|
||||
return out
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -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<string, string>
|
||||
|
||||
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<string, unknown>
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user