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/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
- let [_, summary, _description] = meta?.head?.match(/^([^\n]*)\n\n(.*)/) || []
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')}${(summary ? ` ${summary}` : '').replace(/\n/, '')}\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
+ }