galbe 0.15.6 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/bin/commands/build.ts +30 -19
- package/bin/commands/dev.ts +53 -5
- package/bin/commands/generate/cli/index.ts +4 -1
- package/bin/commands/generate/client.ts +61 -30
- package/bin/commands/generate/code/openapi.parser.ts +440 -163
- package/bin/commands/generate/code/route-merge.ts +26 -21
- package/bin/commands/generate/code.ts +15 -1
- package/bin/commands/generate/model.ts +4 -1
- package/bin/commands/generate/spec.ts +3 -1
- package/bin/res/client.runtime.ts +5 -0
- package/bin/util.ts +36 -90
- package/package.json +34 -9
- package/src/cookies.ts +29 -8
- package/src/extras/spec/openapi.serializer.ts +287 -99
- package/src/extras.ts +1 -1
- package/src/index.ts +377 -71
- package/src/middlewares/_auth.ts +178 -0
- package/src/middlewares/apiKey.ts +139 -0
- package/src/middlewares/basicAuth.ts +151 -0
- package/src/middlewares/bearer.ts +136 -0
- package/src/middlewares/jwt.ts +455 -0
- package/src/middlewares/logger.ts +120 -0
- package/src/middlewares/rateLimit.ts +153 -0
- package/src/middlewares/requestId.ts +94 -0
- package/src/middlewares/timing.ts +86 -0
- package/src/middlewares.ts +53 -0
- package/src/parser.ts +279 -133
- package/src/router.ts +74 -51
- package/src/routes.ts +220 -136
- package/src/schema.ts +123 -31
- package/src/server.ts +130 -70
- package/src/types.ts +366 -90
- package/src/util.ts +271 -5
- package/src/validator.compile.ts +343 -0
- package/src/validator.ts +64 -18
- package/bin/res/client.template.ts +0 -200
- package/scripts/release.ts +0 -196
package/src/util.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type { Method, Route, RouteNode } from '.'
|
|
1
|
+
import type { GalbeMiddleware, Method, Route, RouteNode } from '.'
|
|
2
2
|
import type { RouteFileMeta, RouteMeta } from './routes'
|
|
3
3
|
|
|
4
|
-
const METHOD_COLOR: Record<string, string> = {
|
|
4
|
+
export const METHOD_COLOR: Record<string, string> = {
|
|
5
5
|
get: '\x1b[32m',
|
|
6
6
|
post: '\x1b[34m',
|
|
7
7
|
put: '\x1b[36m',
|
|
@@ -26,6 +26,75 @@ export const softMerge = <T>(base: T, override: T): T => {
|
|
|
26
26
|
}
|
|
27
27
|
return base
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Split a route's JSDoc head into its `summary`/`description` pair: the first
|
|
31
|
+
* paragraph is the summary, whatever follows the first blank line is the
|
|
32
|
+
* description. A head with no blank line is a summary alone — the single
|
|
33
|
+
* source of truth for every consumer (route log, OpenAPI spec).
|
|
34
|
+
*/
|
|
35
|
+
export const splitHead = (head?: string): { summary?: string; description?: string } => {
|
|
36
|
+
if (!head) return {}
|
|
37
|
+
const blank = head.indexOf('\n\n')
|
|
38
|
+
if (blank === -1) {
|
|
39
|
+
const nl = head.indexOf('\n')
|
|
40
|
+
return { summary: (nl === -1 ? head : head.slice(0, nl)).trim() || undefined }
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
summary: head.slice(0, blank).trim() || undefined,
|
|
44
|
+
description: head.slice(blank + 2).trim() || undefined,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* An operation's `summary` / `description` pair. The JSDoc head is the default
|
|
49
|
+
* — first paragraph is the summary, whatever follows the first blank line is
|
|
50
|
+
* the description — and an explicit `@summary` / `@description` tag overrides
|
|
51
|
+
* its half of it. A bare `@summary` with no text means "explicitly no summary",
|
|
52
|
+
* which is the one way to write a description without one.
|
|
53
|
+
*
|
|
54
|
+
* Repeated `@description` lines join into a multi-line description; a repeated
|
|
55
|
+
* `@summary` keeps the first.
|
|
56
|
+
*/
|
|
57
|
+
export const routeHead = (meta?: Record<string, any>): { summary?: string; description?: string } => {
|
|
58
|
+
const split = splitHead(meta?.head)
|
|
59
|
+
const first = (v: unknown) => (Array.isArray(v) ? v[0] : v)
|
|
60
|
+
const summaryTag = first(meta?.summary)
|
|
61
|
+
const descriptionTag = meta?.description
|
|
62
|
+
// a bare tag carries `true`: the author named the field and left it empty
|
|
63
|
+
const summary = summaryTag === true ? '' : typeof summaryTag === 'string' ? summaryTag.trim() : split.summary
|
|
64
|
+
const description =
|
|
65
|
+
descriptionTag === true
|
|
66
|
+
? ''
|
|
67
|
+
: Array.isArray(descriptionTag)
|
|
68
|
+
? descriptionTag.filter(d => typeof d === 'string').join('\n')
|
|
69
|
+
: typeof descriptionTag === 'string'
|
|
70
|
+
? descriptionTag
|
|
71
|
+
: split.description
|
|
72
|
+
return { summary, description }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The wildcard status ranges a response map may be keyed by, as OpenAPI spells them. */
|
|
76
|
+
export const RESPONSE_RANGES = ['1XX', '2XX', '3XX', '4XX', '5XX'] as const
|
|
77
|
+
/** The range key covering `status` — `404` → `'4XX'`. */
|
|
78
|
+
export const responseRangeOf = (status: number) => `${Math.floor(status / 100)}XX`
|
|
79
|
+
/** Default descriptions for the range keys, used when a response declares none. */
|
|
80
|
+
export const RESPONSE_RANGE_DESCRIPTION: Record<string, string> = {
|
|
81
|
+
'1XX': 'Informational',
|
|
82
|
+
'2XX': 'Successful',
|
|
83
|
+
'3XX': 'Redirection',
|
|
84
|
+
'4XX': 'Client error',
|
|
85
|
+
'5XX': 'Server error',
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The response-map entry that governs `status`. Precedence is OpenAPI's: an
|
|
89
|
+
* exact status wins over the `NXX` range containing it, which wins over
|
|
90
|
+
* `default`. Every consumer of a response map resolves through this, so
|
|
91
|
+
* validation, content-type inference and the spec agree on one answer.
|
|
92
|
+
*/
|
|
93
|
+
export const responseEntryFor = <T>(
|
|
94
|
+
response: Partial<Record<string | number, T>> | undefined,
|
|
95
|
+
status: number
|
|
96
|
+
): T | undefined => response?.[status] ?? response?.[responseRangeOf(status)] ?? response?.['default']
|
|
97
|
+
|
|
29
98
|
export const logRoute = (
|
|
30
99
|
r: { method: string; path: string; static?: { path: string; root: string } },
|
|
31
100
|
meta?: RouteMeta,
|
|
@@ -44,11 +113,10 @@ export const logRoute = (
|
|
|
44
113
|
if (meta?.deprecated) routeLog = `\x1b[0;9m\x1b[38;5;244m${routeLog.replaceAll(ansiRegex, '')}\x1b[0m`
|
|
45
114
|
} else {
|
|
46
115
|
let color = METHOD_COLOR?.[method] || ''
|
|
47
|
-
|
|
48
|
-
if (!summary) _description = meta?.head || ''
|
|
116
|
+
const { summary } = routeHead(meta)
|
|
49
117
|
routeLog = `[${color}${`${method.toUpperCase()}\x1b[0m]`.padEnd(12, ' ')} ${path
|
|
50
118
|
.padEnd(format?.maxPathLength ?? path.length, ' ')
|
|
51
|
-
.replaceAll(/:([^\/]+)/g, '\x1b[0;33m:$1\x1b[0m')}${
|
|
119
|
+
.replaceAll(/:([^\/]+)/g, '\x1b[0;33m:$1\x1b[0m')}${summary ? ` ${summary.replaceAll(/\s+/g, ' ')}` : ''}\x1b[0m`
|
|
52
120
|
if (meta?.deprecated) routeLog = `\x1b[0;9m\x1b[38;5;244m${routeLog.replaceAll(ansiRegex, '')}\x1b[0m`
|
|
53
121
|
}
|
|
54
122
|
|
|
@@ -82,6 +150,64 @@ export const walkMetaRoutes = (
|
|
|
82
150
|
|
|
83
151
|
export const isIterator = (obj: any) => typeof obj?.next === 'function'
|
|
84
152
|
|
|
153
|
+
export const joinPath = (prefix: string, path: string) => {
|
|
154
|
+
if (prefix && prefix[0] !== '/') prefix = `/${prefix}`
|
|
155
|
+
prefix = prefix.replace(/\/+$/, '')
|
|
156
|
+
return `${prefix}${path[0] === '/' ? path : `/${path}`}`
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// middleware pattern segments are literals or '*'; ':params' are a routing
|
|
160
|
+
// concept and rejected here ('*' already matches any single segment)
|
|
161
|
+
export const parseMiddlewarePattern = (pattern: string): string[] => {
|
|
162
|
+
const segments = pattern.split('/').filter(s => s !== '')
|
|
163
|
+
const valid = pattern === '/' || (segments.length && segments.every(s => s === '*' || !/[:*\s]/.test(s)))
|
|
164
|
+
if (!valid) throw new SyntaxError(`${pattern} is not a valid middleware pattern (segments are literals or '*')`)
|
|
165
|
+
return segments
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// literal segments match identical route segments, '*' matches any single
|
|
169
|
+
// segment (including ':params'), a trailing '*' matches the whole subtree and
|
|
170
|
+
// the prefix itself (like the router's terminal wildcard)
|
|
171
|
+
export const matchMiddleware = (pattern: string[], path: string[]): boolean => {
|
|
172
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
173
|
+
const p = pattern[i]!
|
|
174
|
+
if (p === '*' && i === pattern.length - 1) return true
|
|
175
|
+
if (i >= path.length || (p !== '*' && p !== path[i])) return false
|
|
176
|
+
}
|
|
177
|
+
return pattern.length === path.length
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The route's schema as declared, kept aside so recomposition re-merges from a clean base. */
|
|
181
|
+
const Declared = Symbol.for('Galbe.Route.DeclaredSchema')
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Merges the matched middlewares' schema fragments into a route's request
|
|
185
|
+
* schema — fragments in registration order, route-declared keys last, so a
|
|
186
|
+
* route can always tighten or override. Returns false when there was nothing
|
|
187
|
+
* to merge.
|
|
188
|
+
*
|
|
189
|
+
* Merging is always redone from the declared schema, never from the result of
|
|
190
|
+
* a previous merge: a route recomposes whenever a later `middleware()` call
|
|
191
|
+
* matches it, and a fragment key already merged in would otherwise shadow the
|
|
192
|
+
* newcomer's, making the outcome depend on registration order.
|
|
193
|
+
*
|
|
194
|
+
* Copy-on-merge: a `RequestSchema` object may be shared by several routes, so
|
|
195
|
+
* the route gets its own copy instead of the user's being mutated.
|
|
196
|
+
*/
|
|
197
|
+
export const mergeMiddlewareSchema = (route: Route, middlewares: GalbeMiddleware[]): boolean => {
|
|
198
|
+
const fragments = middlewares.flatMap(m => (m.schema ? [m.schema] : []))
|
|
199
|
+
if (!fragments.length) return false
|
|
200
|
+
const declared: Record<string, any> = (route as any)[Declared] ?? route.schema ?? {}
|
|
201
|
+
;(route as any)[Declared] = declared
|
|
202
|
+
const schema: Record<string, any> = { ...declared }
|
|
203
|
+
for (const key of ['headers', 'query', 'params'] as const) {
|
|
204
|
+
const parts = fragments.flatMap(f => (f[key] ? [f[key]] : []))
|
|
205
|
+
if (parts.length) schema[key] = Object.assign({}, ...parts, declared[key])
|
|
206
|
+
}
|
|
207
|
+
route.schema = schema
|
|
208
|
+
return true
|
|
209
|
+
}
|
|
210
|
+
|
|
85
211
|
export const HttpStatus = {
|
|
86
212
|
100: 'Continue',
|
|
87
213
|
101: 'Switching Protocols',
|
|
@@ -160,3 +286,143 @@ export const inferBodyType = (contentType?: string | null): ParseMode => {
|
|
|
160
286
|
if (BA_HEADER_RX.test(contentType)) return 'byteArray'
|
|
161
287
|
return 'default'
|
|
162
288
|
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Trusted proxy resolution: turning the socket peer and an `X-Forwarded-For`
|
|
292
|
+
* header into the address of the actual client. Everything below is fed
|
|
293
|
+
* attacker-controlled text, so nothing here throws and nothing is trusted
|
|
294
|
+
* before it parses as an IP.
|
|
295
|
+
*/
|
|
296
|
+
|
|
297
|
+
/** Beyond this many hops the header is treated as unusable and the socket peer wins. */
|
|
298
|
+
const MAX_FORWARDED_HOPS = 32
|
|
299
|
+
const IPV4_RX = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/
|
|
300
|
+
const IPV6_GROUP_RX = /^[0-9a-f]{1,4}$/
|
|
301
|
+
|
|
302
|
+
/** Dotted quad → 4 bytes. Leading zeros are rejected: one address, one spelling. */
|
|
303
|
+
const ipv4Bytes = (ip: string) => {
|
|
304
|
+
const m = IPV4_RX.exec(ip)
|
|
305
|
+
if (!m) return undefined
|
|
306
|
+
const bytes = new Uint8Array(4)
|
|
307
|
+
for (let i = 0; i < 4; i++) {
|
|
308
|
+
const part = m[i + 1]!
|
|
309
|
+
const value = Number(part)
|
|
310
|
+
if (value > 255 || (part.length > 1 && part[0] === '0')) return undefined
|
|
311
|
+
bytes[i] = value
|
|
312
|
+
}
|
|
313
|
+
return bytes
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** RFC 4291 text form → 16 bytes: one `::` run, and an optional trailing dotted quad. */
|
|
317
|
+
const ipv6Bytes = (ip: string) => {
|
|
318
|
+
const [head, tail, extra] = ip.split('::')
|
|
319
|
+
if (extra !== undefined) return undefined
|
|
320
|
+
const left = head ? head.split(':') : []
|
|
321
|
+
const right = tail ? tail.split(':') : []
|
|
322
|
+
const groups = right.length ? right : left
|
|
323
|
+
let v4: Uint8Array | undefined
|
|
324
|
+
if (groups.at(-1)?.includes('.')) {
|
|
325
|
+
v4 = ipv4Bytes(groups.pop()!)
|
|
326
|
+
if (!v4) return undefined
|
|
327
|
+
}
|
|
328
|
+
const count = left.length + right.length + (v4 ? 2 : 0)
|
|
329
|
+
// without a `::` run every group must be spelled out
|
|
330
|
+
if (count > 8 || (tail === undefined && count !== 8)) return undefined
|
|
331
|
+
const bytes = new Uint8Array(16)
|
|
332
|
+
const write = (group: string, at: number) => {
|
|
333
|
+
if (!IPV6_GROUP_RX.test(group)) return false
|
|
334
|
+
const value = parseInt(group, 16)
|
|
335
|
+
bytes[at] = value >> 8
|
|
336
|
+
bytes[at + 1] = value & 0xff
|
|
337
|
+
return true
|
|
338
|
+
}
|
|
339
|
+
for (let i = 0; i < left.length; i++) if (!write(left[i]!, i * 2)) return undefined
|
|
340
|
+
const start = 16 - right.length * 2 - (v4 ? 4 : 0)
|
|
341
|
+
for (let i = 0; i < right.length; i++) if (!write(right[i]!, start + i * 2)) return undefined
|
|
342
|
+
if (v4) bytes.set(v4, 12)
|
|
343
|
+
return bytes
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const ipBytes = (ip: string) => (ip.includes(':') ? ipv6Bytes(ip) : ipv4Bytes(ip))
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Reads one hop — a socket peer or an `X-Forwarded-For` entry — into its
|
|
350
|
+
* canonical text form and its bytes. Brackets and a trailing port are stripped
|
|
351
|
+
* (`[::1]:8080`, `192.0.2.1:8080`) and an IPv4-mapped address is unwrapped, so
|
|
352
|
+
* one client cannot present itself under several spellings.
|
|
353
|
+
*/
|
|
354
|
+
const parseHop = (raw: string) => {
|
|
355
|
+
let address = raw.trim().toLowerCase()
|
|
356
|
+
if (address[0] === '[') {
|
|
357
|
+
const close = address.indexOf(']')
|
|
358
|
+
if (close < 0) return undefined
|
|
359
|
+
address = address.slice(1, close)
|
|
360
|
+
} else {
|
|
361
|
+
const colon = address.indexOf(':')
|
|
362
|
+
if (colon > 0 && address.includes('.') && address.indexOf(':', colon + 1) < 0) address = address.slice(0, colon)
|
|
363
|
+
}
|
|
364
|
+
if (address.startsWith('::ffff:') && address.includes('.')) address = address.slice(7)
|
|
365
|
+
const bytes = ipBytes(address)
|
|
366
|
+
return bytes && { address, bytes }
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
type Cidr = { bytes: Uint8Array; bits: number }
|
|
370
|
+
const parseCidr = (entry: string): Cidr => {
|
|
371
|
+
const slash = entry.lastIndexOf('/')
|
|
372
|
+
const suffix = slash < 0 ? undefined : entry.slice(slash + 1)
|
|
373
|
+
const hop = parseHop(slash < 0 ? entry : entry.slice(0, slash))
|
|
374
|
+
if (!hop) throw new SyntaxError(`trustProxy: '${entry}' is not an IP address or CIDR range`)
|
|
375
|
+
const bits = suffix === undefined ? hop.bytes.length * 8 : Number(suffix)
|
|
376
|
+
if (suffix !== undefined && (!/^\d+$/.test(suffix) || bits > hop.bytes.length * 8))
|
|
377
|
+
throw new SyntaxError(`trustProxy: '${entry}' has an out-of-range prefix length`)
|
|
378
|
+
return { bytes: hop.bytes, bits }
|
|
379
|
+
}
|
|
380
|
+
const inRange = (ip: Uint8Array, { bytes, bits }: Cidr) => {
|
|
381
|
+
if (ip.length !== bytes.length) return false
|
|
382
|
+
const whole = bits >> 3
|
|
383
|
+
for (let i = 0; i < whole; i++) if (ip[i] !== bytes[i]) return false
|
|
384
|
+
const rest = bits & 7
|
|
385
|
+
return !rest || (ip[whole]! ^ bytes[whole]!) >> (8 - rest) === 0
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Compiles a `trustProxy` config into the per-request client address resolver.
|
|
390
|
+
* The hop chain is the socket peer followed by the `X-Forwarded-For` entries
|
|
391
|
+
* **right to left** — the rightmost entry is the nearest hop, and the only one
|
|
392
|
+
* your own infrastructure wrote. A hop count discards that many entries; a list
|
|
393
|
+
* of ranges discards hops it recognizes and stops at the first it does not.
|
|
394
|
+
*
|
|
395
|
+
* The socket peer wins whenever the chain cannot be walked as configured — an
|
|
396
|
+
* unparseable hop, a header shorter than the hop count, more than
|
|
397
|
+
* {@link MAX_FORWARDED_HOPS} hops — rather than falling through to the
|
|
398
|
+
* leftmost, client-controlled entry. Config errors throw here, at boot.
|
|
399
|
+
*/
|
|
400
|
+
export const clientAddressResolver = (trustProxy?: false | number | string[]) => {
|
|
401
|
+
const hops = typeof trustProxy === 'number' ? trustProxy : undefined
|
|
402
|
+
if (hops !== undefined && (!Number.isInteger(hops) || hops < 0))
|
|
403
|
+
throw new SyntaxError('trustProxy: hop count must be a positive integer')
|
|
404
|
+
const trusted = Array.isArray(trustProxy) ? trustProxy.map(parseCidr) : undefined
|
|
405
|
+
if (!hops && !trusted?.length) return (_req: Request, peer: string | null) => peer
|
|
406
|
+
|
|
407
|
+
return (req: Request, peer: string | null) => {
|
|
408
|
+
const socket = peer ? parseHop(peer) : undefined
|
|
409
|
+
const header = req.headers.get('x-forwarded-for')
|
|
410
|
+
if (!socket || !header) return peer
|
|
411
|
+
let hop: { address: string; bytes: Uint8Array } | undefined
|
|
412
|
+
let end = header.length
|
|
413
|
+
for (let i = 0; i < MAX_FORWARDED_HOPS; i++) {
|
|
414
|
+
// is the hop under examination one of ours? if not, it is the client
|
|
415
|
+
if (hops !== undefined ? i >= hops : !trusted!.some(range => inRange((hop ?? socket).bytes, range)))
|
|
416
|
+
return hop?.address ?? peer
|
|
417
|
+
// out of entries: a hop count that overruns the header is a misconfiguration
|
|
418
|
+
// and falls back, whereas an all-trusted chain leaves the leftmost entry
|
|
419
|
+
if (end <= 0) return hops !== undefined ? peer : (hop?.address ?? peer)
|
|
420
|
+
const comma = header.lastIndexOf(',', end - 1)
|
|
421
|
+
const next = parseHop(header.slice(comma + 1, end))
|
|
422
|
+
if (!next) return peer
|
|
423
|
+
hop = next
|
|
424
|
+
end = comma
|
|
425
|
+
}
|
|
426
|
+
return peer
|
|
427
|
+
}
|
|
428
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
STArray,
|
|
3
|
+
STByteArray,
|
|
4
|
+
STInteger,
|
|
5
|
+
STIntersection,
|
|
6
|
+
STJson,
|
|
7
|
+
STLiteral,
|
|
8
|
+
STMultipartForm,
|
|
9
|
+
STNumber,
|
|
10
|
+
STObject,
|
|
11
|
+
STSchema,
|
|
12
|
+
STString,
|
|
13
|
+
STUnion,
|
|
14
|
+
} from './schema'
|
|
15
|
+
import type { RequestSchema, STBody } from './types'
|
|
16
|
+
import { Kind, Optional, NUMBER_FORMAT_RANGES } from './schema'
|
|
17
|
+
import { validate, preview, isMultipleOf } from './validator'
|
|
18
|
+
|
|
19
|
+
/** A validator specialized to a single schema, with the same contract as `validate`. */
|
|
20
|
+
export type CompiledValidator = (elt: any, opt?: { parse?: boolean }) => any
|
|
21
|
+
|
|
22
|
+
// Compiled validators live on their schema object under a symbol key, so they
|
|
23
|
+
// stay invisible to Object.entries, JSON.stringify and the spec serializers.
|
|
24
|
+
export const Compiled = Symbol.for('Galbe.Validator.Compiled')
|
|
25
|
+
|
|
26
|
+
/** Runs the compiled validator attached to `schema`, falling back to the `validate` interpreter. */
|
|
27
|
+
export const runCompiled = (elt: any, schema: STSchema, opt?: { parse?: boolean }): any => {
|
|
28
|
+
const c = (schema as any)[Compiled] as CompiledValidator | undefined
|
|
29
|
+
return c ? c(elt, opt) : validate(elt, schema, opt)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Compiles `schema` into a specialized validator, cached on the schema object
|
|
34
|
+
* itself. Nested schemas are compiled recursively, so per-prop parsers
|
|
35
|
+
* (paramParser, multipart) find compiled validators too. Returns undefined for
|
|
36
|
+
* kinds the compiler doesn't support — callers fall back to the interpreter.
|
|
37
|
+
*/
|
|
38
|
+
export const compile = (schema?: STSchema): CompiledValidator | undefined => {
|
|
39
|
+
if (!schema?.[Kind]) return undefined
|
|
40
|
+
const cached = (schema as any)[Compiled] as CompiledValidator | undefined
|
|
41
|
+
if (cached) return cached
|
|
42
|
+
let fn: CompiledValidator | undefined
|
|
43
|
+
try {
|
|
44
|
+
fn = build(schema)
|
|
45
|
+
} catch {
|
|
46
|
+
// a malformed schema must not break boot — the interpreter reports it at request time
|
|
47
|
+
fn = undefined
|
|
48
|
+
}
|
|
49
|
+
if (fn) (schema as any)[Compiled] = fn
|
|
50
|
+
return fn
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Compiles every schema reachable from a route's request schema. Called at route registration time. */
|
|
54
|
+
export const compileRoute = (schema: RequestSchema) => {
|
|
55
|
+
for (const props of [schema.headers, schema.params, schema.query, schema.cookies])
|
|
56
|
+
if (props) for (const s of Object.values(props)) compile(s as STSchema)
|
|
57
|
+
const body = schema.body as STBody | undefined
|
|
58
|
+
if (body) {
|
|
59
|
+
if ((body as STSchema)[Kind]) compile(body as STSchema)
|
|
60
|
+
else for (const s of Object.values(body)) compile(s as STSchema)
|
|
61
|
+
}
|
|
62
|
+
for (const entry of Object.values(schema.response ?? {})) {
|
|
63
|
+
if (!entry) continue
|
|
64
|
+
if ((entry as STSchema)[Kind]) compile(entry as STSchema)
|
|
65
|
+
// response content maps also carry non-schema keys (description, responseHeaders) — compile ignores them
|
|
66
|
+
else for (const s of Object.values(entry)) compile(s as STSchema)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Compiled equivalents of schemaValidation(): bounds and messages are closed
|
|
71
|
+
// over at compile time. Like the interpreter, a single failure throws the
|
|
72
|
+
// message, several throw an array of messages.
|
|
73
|
+
const constraintRunner = (checks: ((v: any) => string | undefined)[]) => {
|
|
74
|
+
if (!checks.length) return undefined
|
|
75
|
+
return (v: any) => {
|
|
76
|
+
const errors: string[] = []
|
|
77
|
+
for (const check of checks) {
|
|
78
|
+
const msg = check(v)
|
|
79
|
+
if (msg !== undefined) errors.push(msg)
|
|
80
|
+
}
|
|
81
|
+
if (errors.length === 1) throw errors[0]
|
|
82
|
+
if (errors.length > 1) throw errors
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const numberConstraints = (n: STNumber | STInteger) => {
|
|
87
|
+
const checks: ((v: number) => string | undefined)[] = []
|
|
88
|
+
if (n.exclusiveMin !== undefined) {
|
|
89
|
+
const b = n.exclusiveMin
|
|
90
|
+
checks.push(v => (v <= b ? `Is less or equal to ${b}` : undefined))
|
|
91
|
+
}
|
|
92
|
+
if (n.exclusiveMax !== undefined) {
|
|
93
|
+
const b = n.exclusiveMax
|
|
94
|
+
checks.push(v => (v >= b ? `Is greater or equal to ${b}` : undefined))
|
|
95
|
+
}
|
|
96
|
+
if (n.min !== undefined) {
|
|
97
|
+
const b = n.min
|
|
98
|
+
checks.push(v => (v < b ? `Is less than ${b}` : undefined))
|
|
99
|
+
}
|
|
100
|
+
if (n.max !== undefined) {
|
|
101
|
+
const b = n.max
|
|
102
|
+
checks.push(v => (v > b ? `Is greater than ${b}` : undefined))
|
|
103
|
+
}
|
|
104
|
+
if (n.multipleOf !== undefined) {
|
|
105
|
+
const m = n.multipleOf
|
|
106
|
+
const msg = `Is not a multiple of ${m}`
|
|
107
|
+
checks.push(v => (!isMultipleOf(v, m) ? msg : undefined))
|
|
108
|
+
}
|
|
109
|
+
const range = n.format ? NUMBER_FORMAT_RANGES[n.format] : undefined
|
|
110
|
+
if (range) {
|
|
111
|
+
const [lo, hi] = range
|
|
112
|
+
const msg = `Is out of ${n.format} range`
|
|
113
|
+
checks.push(v => (v < lo || v > hi ? msg : undefined))
|
|
114
|
+
}
|
|
115
|
+
return constraintRunner(checks)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const stringConstraints = (s: STString) => {
|
|
119
|
+
const checks: ((v: string) => string | undefined)[] = []
|
|
120
|
+
if (s.minLength !== undefined) {
|
|
121
|
+
const b = s.minLength
|
|
122
|
+
checks.push(v => (v.length < b ? `Length is too small (${b} char min)` : undefined))
|
|
123
|
+
}
|
|
124
|
+
if (s.maxLength !== undefined) {
|
|
125
|
+
const b = s.maxLength
|
|
126
|
+
checks.push(v => (v.length > b ? `Length is too large (${b} char max)` : undefined))
|
|
127
|
+
}
|
|
128
|
+
if (s.pattern !== undefined) {
|
|
129
|
+
const p = s.pattern
|
|
130
|
+
const msg = `Does not match pattern ${p}`
|
|
131
|
+
checks.push(v => (!v.match(p) ? msg : undefined))
|
|
132
|
+
}
|
|
133
|
+
return constraintRunner(checks)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const byteArrayConstraints = (ba: STByteArray) => {
|
|
137
|
+
const checks: ((v: Uint8Array) => string | undefined)[] = []
|
|
138
|
+
if (ba.minLength !== undefined) {
|
|
139
|
+
const b = ba.minLength
|
|
140
|
+
checks.push(v => (v.length < b ? `Length is too small (${b} bytes min)` : undefined))
|
|
141
|
+
}
|
|
142
|
+
if (ba.maxLength !== undefined) {
|
|
143
|
+
const b = ba.maxLength
|
|
144
|
+
checks.push(v => (v.length > b ? `Length is too large (${b} bytes max)` : undefined))
|
|
145
|
+
}
|
|
146
|
+
return constraintRunner(checks)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const arrayConstraints = (arr: STArray) => {
|
|
150
|
+
const checks: ((v: any[]) => string | undefined)[] = []
|
|
151
|
+
if (arr.minLength !== undefined) {
|
|
152
|
+
const b = arr.minLength
|
|
153
|
+
checks.push(v => (v.length < b ? `Must contain at least ${b} item${b > 1 ? 's' : ''}` : undefined))
|
|
154
|
+
}
|
|
155
|
+
if (arr.maxLength !== undefined) {
|
|
156
|
+
const b = arr.maxLength
|
|
157
|
+
checks.push(v => (v.length > b ? `Must contain at most ${b} item${b > 1 ? 's' : ''}` : undefined))
|
|
158
|
+
}
|
|
159
|
+
if (arr.unique === true) checks.push(v => (new Set(v).size !== v.length ? `Has duplicate values` : undefined))
|
|
160
|
+
return constraintRunner(checks)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Builds the specialized validator for one schema. Must accept/reject exactly
|
|
164
|
+
// the same inputs and throw the same plain-string errors as `validate`.
|
|
165
|
+
const build = (schema: STSchema): CompiledValidator | undefined => {
|
|
166
|
+
switch (schema[Kind]) {
|
|
167
|
+
case 'null':
|
|
168
|
+
return elt => {
|
|
169
|
+
if (elt !== null) throw `Expected null value got ${preview(elt)}`
|
|
170
|
+
return elt
|
|
171
|
+
}
|
|
172
|
+
case 'boolean':
|
|
173
|
+
return (elt, opt) => {
|
|
174
|
+
if (typeof elt === 'string') {
|
|
175
|
+
if (opt?.parse) elt = elt === 'true' ? true : elt === 'false' ? false : null
|
|
176
|
+
else throw `Expected boolean, got string.`
|
|
177
|
+
}
|
|
178
|
+
if (elt !== true && elt !== false) throw `Not a valid boolean. Should be 'true' or 'false'`
|
|
179
|
+
return elt
|
|
180
|
+
}
|
|
181
|
+
case 'integer': {
|
|
182
|
+
const constraints = numberConstraints(schema as STInteger)
|
|
183
|
+
return (elt, opt) => {
|
|
184
|
+
if (elt === '') throw `Not a valid integer`
|
|
185
|
+
if (opt?.parse && typeof elt === 'string') elt = Number(elt)
|
|
186
|
+
if (!Number.isInteger(elt)) throw `Not a valid integer`
|
|
187
|
+
constraints?.(elt)
|
|
188
|
+
return elt
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
case 'number': {
|
|
192
|
+
const constraints = numberConstraints(schema as STNumber)
|
|
193
|
+
return (elt, opt) => {
|
|
194
|
+
if (elt === '') throw `Not a valid number`
|
|
195
|
+
if (opt?.parse && typeof elt === 'string') elt = Number(elt)
|
|
196
|
+
if (!Number.isFinite(elt)) throw `Not a valid number`
|
|
197
|
+
constraints?.(elt)
|
|
198
|
+
return elt
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
case 'string': {
|
|
202
|
+
const constraints = stringConstraints(schema as STString)
|
|
203
|
+
return elt => {
|
|
204
|
+
if (typeof elt !== 'string') throw `Not a valid string`
|
|
205
|
+
constraints?.(elt)
|
|
206
|
+
return elt
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
case 'literal': {
|
|
210
|
+
const value = (schema as STLiteral).value
|
|
211
|
+
return elt => {
|
|
212
|
+
if (elt !== value) throw `Not a valid value. Found "${preview(elt)}" but expected "${value}"`
|
|
213
|
+
return elt
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
case 'object': {
|
|
217
|
+
const props = Object.entries((schema as STObject).props ?? {}).map(([k, s]) => ({
|
|
218
|
+
k,
|
|
219
|
+
s: s as STSchema,
|
|
220
|
+
c: compile(s as STSchema),
|
|
221
|
+
required: !(s as STSchema)?.[Optional],
|
|
222
|
+
}))
|
|
223
|
+
const declared = new Set(props.map(p => p.k))
|
|
224
|
+
const ap = (schema as STObject).additionalProperties
|
|
225
|
+
const apCompiled = ap ? compile(ap) : undefined
|
|
226
|
+
return (elt, opt) => {
|
|
227
|
+
if (opt?.parse && typeof elt === 'string') {
|
|
228
|
+
try {
|
|
229
|
+
elt = JSON.parse(elt)
|
|
230
|
+
} catch {
|
|
231
|
+
throw `Not a valid object`
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (elt === null || typeof elt !== 'object') throw `Not a valid object`
|
|
235
|
+
if (Array.isArray(elt)) throw `Expected an object, not an array`
|
|
236
|
+
let err: Record<string, any> | undefined
|
|
237
|
+
for (const { k, s, c, required } of props) {
|
|
238
|
+
if (!(k in elt)) {
|
|
239
|
+
if (required) (err ??= {})[k] = 'Required'
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
if (c) c(elt[k], opt)
|
|
244
|
+
else validate(elt[k], s, opt)
|
|
245
|
+
} catch (e) {
|
|
246
|
+
;(err ??= {})[k] = e
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (ap !== undefined) {
|
|
250
|
+
for (const k of Object.keys(elt)) {
|
|
251
|
+
if (declared.has(k)) continue
|
|
252
|
+
if (ap === false) {
|
|
253
|
+
;(err ??= {})[k] = 'Unexpected property'
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
if (apCompiled) apCompiled(elt[k], opt)
|
|
258
|
+
else validate(elt[k], ap, opt)
|
|
259
|
+
} catch (e) {
|
|
260
|
+
;(err ??= {})[k] = e
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (err) throw err
|
|
265
|
+
return elt
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
case 'json': {
|
|
269
|
+
const value = (schema as STJson).value as STSchema
|
|
270
|
+
const c = compile(value)
|
|
271
|
+
return (elt, opt) => (c ? c(elt, opt) : validate(elt, value, opt))
|
|
272
|
+
}
|
|
273
|
+
case 'array': {
|
|
274
|
+
const arr = schema as STArray
|
|
275
|
+
const items = arr.items
|
|
276
|
+
const c = compile(items)
|
|
277
|
+
const constraints = arrayConstraints(arr)
|
|
278
|
+
return (elt, opt) => {
|
|
279
|
+
if (opt?.parse && typeof elt === 'string') {
|
|
280
|
+
try {
|
|
281
|
+
elt = JSON.parse(elt)
|
|
282
|
+
} catch {
|
|
283
|
+
throw 'Not a valid array'
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (!Array.isArray(elt)) throw 'Not a valid array'
|
|
287
|
+
// the interpreter validates items without parse mode — keep parity
|
|
288
|
+
for (const i of elt) {
|
|
289
|
+
if (c) c(i)
|
|
290
|
+
else validate(i, items)
|
|
291
|
+
}
|
|
292
|
+
constraints?.(elt)
|
|
293
|
+
return elt
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
case 'byteArray': {
|
|
297
|
+
const constraints = byteArrayConstraints(schema as STByteArray)
|
|
298
|
+
return (elt, opt) => {
|
|
299
|
+
if (opt?.parse && typeof elt === 'string') elt = Uint8Array.from(elt, chr => chr.charCodeAt(0))
|
|
300
|
+
else if (opt?.parse && Array.isArray(elt)) elt = new Uint8Array(elt)
|
|
301
|
+
if (!(elt instanceof Uint8Array)) throw 'Not a valid byteArray'
|
|
302
|
+
constraints?.(elt)
|
|
303
|
+
return elt
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
case 'anyOf':
|
|
307
|
+
case 'oneOf': {
|
|
308
|
+
const members = Object.values((schema as STUnion).members) as STSchema[]
|
|
309
|
+
const failMsg = `Could not be parsed to any of [${members.map(u => (u as any)?.value ?? u[Kind]).join(', ')}]`
|
|
310
|
+
const pairs = members.map(m => ({ m, c: compile(m) }))
|
|
311
|
+
return (elt, opt) => {
|
|
312
|
+
for (const { m, c } of pairs) {
|
|
313
|
+
try {
|
|
314
|
+
return c ? c(elt, opt) : validate(elt, m, opt)
|
|
315
|
+
} catch {
|
|
316
|
+
continue
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
throw failMsg
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
case 'intersection': {
|
|
323
|
+
const allOf = Object.values((schema as STIntersection<any>).allOf) as STSchema[]
|
|
324
|
+
const pairs = allOf.map(m => ({ m, c: compile(m) }))
|
|
325
|
+
return (elt, opt) => {
|
|
326
|
+
for (const { m, c } of pairs) {
|
|
327
|
+
if (c) c(elt, opt)
|
|
328
|
+
else validate(elt, m, opt)
|
|
329
|
+
}
|
|
330
|
+
return elt
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
case 'any':
|
|
334
|
+
return elt => elt
|
|
335
|
+
case 'multipartForm':
|
|
336
|
+
// validate() has no multipartForm branch — its props are validated
|
|
337
|
+
// individually by the multipart parser, so only compile those
|
|
338
|
+
for (const s of Object.values((schema as STMultipartForm).props ?? {})) compile(s as STSchema)
|
|
339
|
+
return undefined
|
|
340
|
+
default:
|
|
341
|
+
return undefined
|
|
342
|
+
}
|
|
343
|
+
}
|