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/parser.ts
CHANGED
|
@@ -16,11 +16,10 @@ import type {
|
|
|
16
16
|
STArray,
|
|
17
17
|
} from './schema'
|
|
18
18
|
|
|
19
|
-
import { readableStreamToArrayBuffer } from 'bun'
|
|
20
19
|
import { Kind, Optional, Stream } from './schema'
|
|
21
|
-
import {
|
|
22
|
-
import { InternalServerError, RequestError } from './index'
|
|
23
|
-
import { isIterator, inferBodyType, type ParseMode } from './util'
|
|
20
|
+
import { runCompiled } from './validator.compile'
|
|
21
|
+
import { InternalServerError, PayloadTooLargeError, RequestError } from './index'
|
|
22
|
+
import { isIterator, inferBodyType, responseEntryFor, type ParseMode } from './util'
|
|
24
23
|
|
|
25
24
|
const textDecoder = new TextDecoder()
|
|
26
25
|
const textEncoder = new TextEncoder()
|
|
@@ -43,12 +42,58 @@ async function* rsToAsyncIterator(readableStream: ReadableStream) {
|
|
|
43
42
|
}
|
|
44
43
|
}
|
|
45
44
|
|
|
45
|
+
// req.bytes() is untyped and returns ArrayBuffer or Uint8Array depending on
|
|
46
|
+
// body chunking (Bun 1.3) — normalize through arrayBuffer. With a limit,
|
|
47
|
+
// accumulate manually so chunked clients lying about their size (no or forged
|
|
48
|
+
// content-length) are cut off as soon as they cross it.
|
|
49
|
+
const reqBytes = async (req: Request, limit?: number) => {
|
|
50
|
+
if (limit === undefined) return new Uint8Array(await req.arrayBuffer())
|
|
51
|
+
const body = req.body
|
|
52
|
+
if (body === null) return new Uint8Array()
|
|
53
|
+
const chunks: Uint8Array[] = []
|
|
54
|
+
let total = 0
|
|
55
|
+
for await (const chunk of body) {
|
|
56
|
+
total += chunk.length
|
|
57
|
+
if (total > limit) throw new PayloadTooLargeError()
|
|
58
|
+
chunks.push(chunk)
|
|
59
|
+
}
|
|
60
|
+
const res = new Uint8Array(total)
|
|
61
|
+
let offset = 0
|
|
62
|
+
for (const chunk of chunks) {
|
|
63
|
+
res.set(chunk, offset)
|
|
64
|
+
offset += chunk.length
|
|
65
|
+
}
|
|
66
|
+
return res
|
|
67
|
+
}
|
|
68
|
+
const reqText = async (req: Request, limit?: number) =>
|
|
69
|
+
limit === undefined ? req.text() : textDecoder.decode(await reqBytes(req, limit))
|
|
70
|
+
const reqJson = async (req: Request, limit?: number) =>
|
|
71
|
+
limit === undefined ? req.json() : JSON.parse(await reqText(req, limit))
|
|
72
|
+
|
|
73
|
+
// Multipart boundary extraction from the content-type header: parameters are
|
|
74
|
+
// `;`-separated and extra legal parameters (charset, …) must not leak into
|
|
75
|
+
// the boundary value.
|
|
76
|
+
const multipartBoundary = (contentType: string | null): string => {
|
|
77
|
+
for (const param of contentType?.split(';') ?? []) {
|
|
78
|
+
const eq = param.indexOf('=')
|
|
79
|
+
if (eq === -1 || param.slice(0, eq).trim().toLowerCase() !== 'boundary') continue
|
|
80
|
+
let value = param.slice(eq + 1).trim()
|
|
81
|
+
if (value.length > 1 && value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1)
|
|
82
|
+
if (value) return value
|
|
83
|
+
}
|
|
84
|
+
throw new RequestError({ status: 400, payload: { body: `Missing multipart boundary` } })
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// `contentType` is the normalized media type (no parameters); the raw header is
|
|
88
|
+
// read from the request only where its parameters matter (multipart boundary),
|
|
89
|
+
// so body parsing never needs the materialized header map
|
|
46
90
|
export const requestBodyParser = async (
|
|
47
|
-
|
|
48
|
-
headers: Record<string, string>,
|
|
91
|
+
req: Request,
|
|
49
92
|
schemas?: STBody | STNull,
|
|
50
|
-
contentType?: string
|
|
93
|
+
contentType?: string,
|
|
94
|
+
limit?: number
|
|
51
95
|
) => {
|
|
96
|
+
const body = req.body
|
|
52
97
|
const normalizedCT = contentType?.split(';')[0]?.trim()
|
|
53
98
|
let parseMode: ParseMode = inferBodyType(contentType)
|
|
54
99
|
let schema: STBodyValue | STNull | undefined =
|
|
@@ -68,27 +113,28 @@ export const requestBodyParser = async (
|
|
|
68
113
|
// No schema defined, we base parsing on parseMode only
|
|
69
114
|
if (parseMode === 'byteArray') {
|
|
70
115
|
if (body === null) return new Uint8Array()
|
|
71
|
-
return
|
|
116
|
+
return await reqBytes(req, limit)
|
|
72
117
|
} else if (parseMode === 'json') {
|
|
73
118
|
if (body === null) return null
|
|
74
119
|
try {
|
|
75
|
-
return
|
|
120
|
+
return await reqJson(req, limit)
|
|
76
121
|
} catch (err: any) {
|
|
122
|
+
if (err instanceof RequestError) throw err
|
|
77
123
|
throw new RequestError({
|
|
78
124
|
status: 400,
|
|
79
|
-
payload: { body:
|
|
125
|
+
payload: { body: 'Not a valid JSON body' },
|
|
80
126
|
})
|
|
81
127
|
}
|
|
82
128
|
} else if (parseMode === 'text') {
|
|
83
129
|
if (body === null) return ''
|
|
84
|
-
return
|
|
130
|
+
return await reqText(req, limit)
|
|
85
131
|
} else if (parseMode === 'urlForm') {
|
|
86
132
|
if (body === null) return {}
|
|
87
|
-
return await
|
|
133
|
+
return parseUrlForm(await reqText(req, limit))
|
|
88
134
|
} else if (parseMode === 'multipart') {
|
|
89
135
|
if (body === null) return {}
|
|
90
|
-
const boundary = headers
|
|
91
|
-
return await streamToMultipartForm(
|
|
136
|
+
const boundary = multipartBoundary(req.headers.get('content-type'))
|
|
137
|
+
return await streamToMultipartForm(oneChunkStream(await reqBytes(req, limit)), boundary)
|
|
92
138
|
} else return body === null ? null : rsToAsyncIterator(body)
|
|
93
139
|
} else {
|
|
94
140
|
// Schemas found
|
|
@@ -110,7 +156,7 @@ export const requestBodyParser = async (
|
|
|
110
156
|
: new Uint8Array()
|
|
111
157
|
}
|
|
112
158
|
if (isStream) return rsToAsyncIterator(body)
|
|
113
|
-
return
|
|
159
|
+
return await reqBytes(req, limit)
|
|
114
160
|
} else if (parseMode === 'text') {
|
|
115
161
|
if (!kind || !['string', 'boolean', 'number', 'integer', 'anyOf', 'oneOf', 'literal'].includes(kind))
|
|
116
162
|
throw new RequestError({ status: 400, payload: { body: `Not a valid body` } })
|
|
@@ -122,40 +168,44 @@ export const requestBodyParser = async (
|
|
|
122
168
|
controller.close()
|
|
123
169
|
},
|
|
124
170
|
})
|
|
125
|
-
:
|
|
171
|
+
: runCompiled('', schema as STSchema, { parse: true })
|
|
126
172
|
if (isStream) return $streamToString(body)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
return await streamToString(body, schema as STBodyValue)
|
|
173
|
+
const str = await reqText(req, limit)
|
|
174
|
+
if (kind === 'anyOf' || kind === 'oneOf') return unionize(str, schema as STUnion)
|
|
175
|
+
return runCompiled(str, schema as STSchema, { parse: true })
|
|
132
176
|
} else if (parseMode === 'json') {
|
|
133
177
|
if (
|
|
134
178
|
!kind ||
|
|
135
|
-
![
|
|
179
|
+
![
|
|
180
|
+
'object',
|
|
181
|
+
'json',
|
|
182
|
+
'boolean',
|
|
183
|
+
'number',
|
|
184
|
+
'integer',
|
|
185
|
+
'string',
|
|
186
|
+
'array',
|
|
187
|
+
'anyOf',
|
|
188
|
+
'oneOf',
|
|
189
|
+
'intersection',
|
|
190
|
+
].includes(kind)
|
|
136
191
|
)
|
|
137
192
|
throw new RequestError({ status: 400, payload: { body: `Not a valid body` } })
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if (kind === 'intersection') {
|
|
144
|
-
let str = body === null ? 'null' : await streamToString(body)
|
|
145
|
-
let json = JSON.parse(str)
|
|
146
|
-
return intersectionize(json, schema as STIntersection<any>)
|
|
147
|
-
}
|
|
148
|
-
const str = body === null ? 'null' : await streamToString(body)
|
|
193
|
+
// an absent body parses as `null`, matching JSON.parse('null')
|
|
194
|
+
if (kind === 'anyOf' || kind === 'oneOf')
|
|
195
|
+
return unionize(body === null ? null : await reqJson(req, limit), schema as STUnion)
|
|
196
|
+
if (kind === 'intersection')
|
|
197
|
+
return intersectionize(body === null ? null : await reqJson(req, limit), schema as STIntersection<any>)
|
|
149
198
|
let json
|
|
150
199
|
try {
|
|
151
|
-
json =
|
|
200
|
+
json = body === null ? null : await reqJson(req, limit)
|
|
152
201
|
} catch (err: any) {
|
|
202
|
+
if (err instanceof RequestError) throw err
|
|
153
203
|
throw new RequestError({
|
|
154
204
|
status: 400,
|
|
155
|
-
payload: { body:
|
|
205
|
+
payload: { body: 'Not a valid JSON body' },
|
|
156
206
|
})
|
|
157
207
|
}
|
|
158
|
-
return
|
|
208
|
+
return runCompiled(json, schema as STSchema, { parse: true })
|
|
159
209
|
} else if (parseMode === 'urlForm') {
|
|
160
210
|
if (!kind || !['object', 'anyOf', 'oneOf'].includes(kind))
|
|
161
211
|
throw new RequestError({ status: 400, payload: { body: `Not a valid body` } })
|
|
@@ -167,21 +217,11 @@ export const requestBodyParser = async (
|
|
|
167
217
|
controller.close()
|
|
168
218
|
},
|
|
169
219
|
})
|
|
170
|
-
:
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
controller.enqueue(new Uint8Array())
|
|
174
|
-
controller.close()
|
|
175
|
-
},
|
|
176
|
-
}),
|
|
177
|
-
schema as STObject
|
|
178
|
-
)
|
|
179
|
-
if (kind === 'anyOf' || kind === 'oneOf') {
|
|
180
|
-
const b = await streamToUrlForm(body)
|
|
181
|
-
return unionize(b, schema as STUnion)
|
|
182
|
-
}
|
|
220
|
+
: parseUrlForm('', schema as STObject)
|
|
221
|
+
if (kind === 'anyOf' || kind === 'oneOf')
|
|
222
|
+
return unionize(parseUrlForm(await reqText(req, limit)), schema as STUnion)
|
|
183
223
|
if (isStream) return $streamToUrlForm(body, schema as STStream<STObject>)
|
|
184
|
-
else return await
|
|
224
|
+
else return parseUrlForm(await reqText(req, limit), schema as STObject)
|
|
185
225
|
} else if (parseMode === 'multipart') {
|
|
186
226
|
if (kind !== 'multipartForm' && kind !== 'anyOf' && kind !== 'oneOf')
|
|
187
227
|
throw new RequestError({ status: 400, payload: { body: `Not a valid body` } })
|
|
@@ -194,13 +234,18 @@ export const requestBodyParser = async (
|
|
|
194
234
|
},
|
|
195
235
|
})
|
|
196
236
|
: {}
|
|
197
|
-
const boundary = headers
|
|
237
|
+
const boundary = multipartBoundary(req.headers.get('content-type'))
|
|
198
238
|
if (kind === 'anyOf' || kind === 'oneOf') {
|
|
199
|
-
let mp = await streamToMultipartForm(
|
|
239
|
+
let mp = await streamToMultipartForm(oneChunkStream(await reqBytes(req, limit)), boundary, undefined, limit)
|
|
200
240
|
return unionize(mp, schema as STUnion)
|
|
201
241
|
}
|
|
202
|
-
if (isStream) return $streamToMultipartForm(body, boundary, schema as STStream<STMultipartForm
|
|
203
|
-
return streamToMultipartForm(
|
|
242
|
+
if (isStream) return $streamToMultipartForm(body, boundary, schema as STStream<STMultipartForm>, limit)
|
|
243
|
+
return streamToMultipartForm(
|
|
244
|
+
oneChunkStream(await reqBytes(req, limit)),
|
|
245
|
+
boundary,
|
|
246
|
+
schema as STMultipartForm,
|
|
247
|
+
limit
|
|
248
|
+
)
|
|
204
249
|
} else if (parseMode === 'default') {
|
|
205
250
|
throw new RequestError({ status: 400, payload: { body: `Not a valid content-type` } })
|
|
206
251
|
}
|
|
@@ -218,12 +263,6 @@ async function* $streamToString(body: ReadableStream) {
|
|
|
218
263
|
const tail = decoder.decode()
|
|
219
264
|
if (tail) yield tail
|
|
220
265
|
}
|
|
221
|
-
const streamToString = async (body: ReadableStream, schema?: STBodyValue): Promise<any> => {
|
|
222
|
-
let res = ''
|
|
223
|
-
for await (const chunk of $streamToString(body)) res += chunk
|
|
224
|
-
if (schema) return validate(res, schema, { parse: true })
|
|
225
|
-
return res
|
|
226
|
-
}
|
|
227
266
|
async function* $streamToUrlForm(
|
|
228
267
|
body: ReadableStream<Uint8Array>,
|
|
229
268
|
schema?: STStream<STObject>
|
|
@@ -300,13 +339,20 @@ async function* $streamToUrlForm(
|
|
|
300
339
|
payload: { body: `Missing field${reqKeys.length > 1 ? 's' : ''}: ${reqKeys.join(', ')}` },
|
|
301
340
|
})
|
|
302
341
|
}
|
|
303
|
-
|
|
304
|
-
|
|
342
|
+
// Buffered counterpart of $streamToUrlForm with the same pair semantics:
|
|
343
|
+
// segments split on `&`, key/value on the first `=`; bare tokens yield an
|
|
344
|
+
// empty key and are filtered out below.
|
|
345
|
+
const parseUrlForm = (text: string, schema?: STObject) => {
|
|
346
|
+
const entries: [string, any][] = text.split('&').map(seg => {
|
|
347
|
+
const eq = seg.indexOf('=')
|
|
348
|
+
return eq === -1
|
|
349
|
+
? ['', decodeFormComponent(seg)]
|
|
350
|
+
: [decodeFormComponent(seg.slice(0, eq)), decodeFormComponent(seg.slice(eq + 1))]
|
|
351
|
+
})
|
|
305
352
|
const required = Object.fromEntries(
|
|
306
353
|
Object.entries(schema?.props || {}).filter(([_, v]: [string, any]) => !v?.[Optional])
|
|
307
354
|
)
|
|
308
355
|
let errors: Record<string, any> = Object.create(null)
|
|
309
|
-
for await (const chunk of $streamToUrlForm(body)) entries.push(chunk)
|
|
310
356
|
const object: Record<string, any> = Object.create(null)
|
|
311
357
|
for (let e of entries.filter(([k]) => k)) {
|
|
312
358
|
if (e[0] in object) {
|
|
@@ -339,9 +385,32 @@ const streamToUrlForm = async (body: ReadableStream<Uint8Array>, schema?: STObje
|
|
|
339
385
|
})
|
|
340
386
|
return object
|
|
341
387
|
}
|
|
342
|
-
|
|
388
|
+
// First index >= `from` where `needle` fully occurs in `hay`, -1 if none. The
|
|
389
|
+
// native indexOf skips to candidate positions (the needle's first byte) so only
|
|
390
|
+
// candidates are compared byte by byte, instead of every offset of the scan.
|
|
391
|
+
const indexOfSeq = (hay: Uint8Array, needle: Uint8Array, from: number) => {
|
|
392
|
+
if (!needle.length) return -1
|
|
393
|
+
const last = hay.length - needle.length
|
|
394
|
+
for (let i = hay.indexOf(needle[0]!, from); i !== -1 && i <= last; i = hay.indexOf(needle[0]!, i + 1)) {
|
|
395
|
+
let b = 1
|
|
396
|
+
while (b < needle.length && hay[i + b] === needle[b]) b++
|
|
397
|
+
if (b === needle.length) return i
|
|
398
|
+
}
|
|
399
|
+
return -1
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function* $streamToMultipartForm(
|
|
403
|
+
data: ReadableStream<Uint8Array>,
|
|
404
|
+
boundary: string,
|
|
405
|
+
schema?: STMultipartForm,
|
|
406
|
+
limit?: number
|
|
407
|
+
) {
|
|
343
408
|
const bound = textEncoder.encode(boundary)
|
|
344
409
|
const delimiter = textEncoder.encode('\r\n\r\n')
|
|
410
|
+
// A match straddling two chunks is undetectable within a single chunk: carry
|
|
411
|
+
// the tail of the unprocessed bytes into each chunk's scan window so the
|
|
412
|
+
// lookahead never stops at the chunk seam.
|
|
413
|
+
const lookahead = Math.max(bound.length, delimiter.length) - 1
|
|
345
414
|
let rest = new Uint8Array()
|
|
346
415
|
let bK: Uint8Array = new Uint8Array()
|
|
347
416
|
let bV: Uint8Array = new Uint8Array()
|
|
@@ -350,30 +419,25 @@ async function* $streamToMultipartForm(data: ReadableStream<Uint8Array>, boundar
|
|
|
350
419
|
Object.entries(schema?.props || {}).filter(([_, v]: [string, any]) => !v?.[Optional])
|
|
351
420
|
)
|
|
352
421
|
for await (const chunk of data) {
|
|
422
|
+
const carry = rest.subarray(Math.max(0, rest.length - lookahead))
|
|
423
|
+
const scan = new Uint8Array(carry.length + chunk.length)
|
|
424
|
+
scan.set(carry)
|
|
425
|
+
scan.set(chunk, carry.length)
|
|
426
|
+
rest = rest.slice(0, rest.length - carry.length)
|
|
353
427
|
start = 0
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
}
|
|
364
|
-
if (!matchBound) {
|
|
365
|
-
for (let b = 0; b < delimiter.length; b++) {
|
|
366
|
-
if (chunk[i + b] === delimiter[b]) continue
|
|
367
|
-
else {
|
|
368
|
-
matchDelimiter = false
|
|
369
|
-
break
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
if (matchBound) {
|
|
428
|
+
// jump from match to match: whichever of boundary/delimiter comes first
|
|
429
|
+
// (boundary wins a tie, as in the byte-wise scan this replaces)
|
|
430
|
+
for (let p = 0; p < scan.length;) {
|
|
431
|
+
const iB = indexOfSeq(scan, bound, p)
|
|
432
|
+
const iD = indexOfSeq(scan, delimiter, p)
|
|
433
|
+
if (iB === -1 && iD === -1) break
|
|
434
|
+
const isBound = iB !== -1 && (iD === -1 || iB <= iD)
|
|
435
|
+
const i = isBound ? iB : iD
|
|
436
|
+
if (isBound) {
|
|
374
437
|
bV = new Uint8Array(rest.length + i - start)
|
|
375
438
|
bV.set(rest)
|
|
376
|
-
bV.set(
|
|
439
|
+
bV.set(scan.slice(start, i), rest.length)
|
|
440
|
+
if (limit !== undefined && bV.length > limit) throw new PayloadTooLargeError()
|
|
377
441
|
bV = bV.slice(1, bV.length - 4)
|
|
378
442
|
const headers = parseMultipartHeader(textDecoder.decode(bK))
|
|
379
443
|
if (headers) {
|
|
@@ -391,22 +455,22 @@ async function* $streamToMultipartForm(data: ReadableStream<Uint8Array>, boundar
|
|
|
391
455
|
bK = new Uint8Array()
|
|
392
456
|
bV = new Uint8Array()
|
|
393
457
|
start = i + bound.length
|
|
394
|
-
|
|
395
|
-
i = start
|
|
396
|
-
} else if (matchDelimiter) {
|
|
458
|
+
} else {
|
|
397
459
|
bK = new Uint8Array(rest.length + i - start)
|
|
398
460
|
bK.set(rest)
|
|
399
|
-
bK.set(
|
|
461
|
+
bK.set(scan.slice(start, i), rest.length)
|
|
400
462
|
start = i + 3
|
|
401
|
-
rest = new Uint8Array()
|
|
402
|
-
i = start
|
|
403
|
-
}
|
|
404
|
-
if (i === chunk.length - 1) {
|
|
405
|
-
const newRest = new Uint8Array(rest.length + i - start + 1)
|
|
406
|
-
newRest.set(rest)
|
|
407
|
-
newRest.set(chunk.slice(start, i + 1), rest.length)
|
|
408
|
-
rest = newRest
|
|
409
463
|
}
|
|
464
|
+
rest = new Uint8Array()
|
|
465
|
+
p = start
|
|
466
|
+
}
|
|
467
|
+
if (start < scan.length) {
|
|
468
|
+
const newRest = new Uint8Array(rest.length + scan.length - start)
|
|
469
|
+
newRest.set(rest)
|
|
470
|
+
newRest.set(scan.subarray(start), rest.length)
|
|
471
|
+
// rest accumulates the current part across chunks — cap its growth
|
|
472
|
+
if (limit !== undefined && newRest.length > limit) throw new PayloadTooLargeError()
|
|
473
|
+
rest = newRest
|
|
410
474
|
}
|
|
411
475
|
}
|
|
412
476
|
const headers = parseMultipartHeader(textDecoder.decode(bK))
|
|
@@ -469,7 +533,7 @@ const parseMultipartContent = (
|
|
|
469
533
|
try {
|
|
470
534
|
result = JSON.parse(textDecoder.decode(content).trim())
|
|
471
535
|
} catch (err: any) {
|
|
472
|
-
throw new RequestError({ status: 400, payload: { body: { [headers.name]:
|
|
536
|
+
throw new RequestError({ status: 400, payload: { body: { [headers.name]: 'Not a valid JSON part' } } })
|
|
473
537
|
}
|
|
474
538
|
} else if (schema?.props) {
|
|
475
539
|
const prop = schema.props[headers.name]!
|
|
@@ -479,17 +543,17 @@ const parseMultipartContent = (
|
|
|
479
543
|
} catch (err: any) {
|
|
480
544
|
throw new RequestError({
|
|
481
545
|
status: 400,
|
|
482
|
-
payload: { body: { [headers.name]:
|
|
546
|
+
payload: { body: { [headers.name]: 'Not a valid JSON part' } },
|
|
483
547
|
})
|
|
484
548
|
}
|
|
485
549
|
try {
|
|
486
|
-
|
|
550
|
+
runCompiled(result, prop)
|
|
487
551
|
} catch (err) {
|
|
488
552
|
throw new RequestError({ status: 400, payload: { body: { [headers.name]: err } } })
|
|
489
553
|
}
|
|
490
554
|
} else if (prop[Kind] === 'byteArray') {
|
|
491
555
|
try {
|
|
492
|
-
return
|
|
556
|
+
return runCompiled(content, prop)
|
|
493
557
|
} catch (err) {
|
|
494
558
|
throw new RequestError({ status: 400, payload: { body: { [headers.name]: err } } })
|
|
495
559
|
}
|
|
@@ -506,7 +570,7 @@ const parseMultipartContent = (
|
|
|
506
570
|
const s = getProp(schema?.props, headers.name)
|
|
507
571
|
if (s) {
|
|
508
572
|
try {
|
|
509
|
-
|
|
573
|
+
runCompiled(result, s[Kind] === 'array' ? s.items : s)
|
|
510
574
|
} catch (err) {
|
|
511
575
|
throw new RequestError({ status: 400, payload: { body: { [headers.name]: err } } })
|
|
512
576
|
}
|
|
@@ -514,21 +578,34 @@ const parseMultipartContent = (
|
|
|
514
578
|
}
|
|
515
579
|
return result
|
|
516
580
|
}
|
|
517
|
-
|
|
581
|
+
// Feeding the whole buffer as a single chunk keeps the streaming scanner's
|
|
582
|
+
// semantics while skipping per-network-chunk generator overhead.
|
|
583
|
+
const oneChunkStream = (buf: Uint8Array) =>
|
|
584
|
+
new ReadableStream<Uint8Array>({
|
|
585
|
+
start(controller) {
|
|
586
|
+
controller.enqueue(buf)
|
|
587
|
+
controller.close()
|
|
588
|
+
},
|
|
589
|
+
})
|
|
590
|
+
const streamToMultipartForm = async (
|
|
591
|
+
data: ReadableStream<Uint8Array>,
|
|
592
|
+
boundary: string,
|
|
593
|
+
schema?: STMultipartForm,
|
|
594
|
+
limit?: number
|
|
595
|
+
) => {
|
|
518
596
|
const res: Record<string, MultipartFormData> = Object.create(null)
|
|
519
597
|
const errors: Record<string, any> = Object.create(null)
|
|
520
598
|
const required = Object.fromEntries(
|
|
521
599
|
Object.entries(schema?.props || {}).filter(([_, v]: [string, any]) => !v?.[Optional])
|
|
522
600
|
)
|
|
523
|
-
for await (const chunk of $streamToMultipartForm(data, boundary)) {
|
|
601
|
+
for await (const chunk of $streamToMultipartForm(data, boundary, undefined, limit)) {
|
|
524
602
|
const name = chunk.headers.name
|
|
525
603
|
if (name in res) {
|
|
526
604
|
const existing = res[name]!
|
|
527
605
|
if (!Array.isArray(existing.content)) existing.content = [existing.content]
|
|
528
606
|
existing.content.push(chunk.content)
|
|
529
607
|
} else {
|
|
530
|
-
if (getProp(schema?.props, name)?.[Kind] === 'array')
|
|
531
|
-
res[name] = { ...chunk, content: [chunk.content] }
|
|
608
|
+
if (getProp(schema?.props, name)?.[Kind] === 'array') res[name] = { ...chunk, content: [chunk.content] }
|
|
532
609
|
else res[name] = chunk
|
|
533
610
|
}
|
|
534
611
|
delete required[name]
|
|
@@ -537,9 +614,8 @@ const streamToMultipartForm = async (data: ReadableStream<Uint8Array>, boundary:
|
|
|
537
614
|
try {
|
|
538
615
|
const prop = schema.props[name]!
|
|
539
616
|
const entry = res[name]!
|
|
540
|
-
if (Array.isArray(entry.content) && prop[Kind] !== 'array')
|
|
541
|
-
|
|
542
|
-
entry.content = validate(entry.content, prop, {
|
|
617
|
+
if (Array.isArray(entry.content) && prop[Kind] !== 'array') throw `Multiple values found`
|
|
618
|
+
entry.content = runCompiled(entry.content, prop, {
|
|
543
619
|
parse: true,
|
|
544
620
|
})
|
|
545
621
|
if (prop[Kind] === 'array')
|
|
@@ -585,7 +661,7 @@ const paramParser = (
|
|
|
585
661
|
} else if (value === null) return null
|
|
586
662
|
else if (Array.isArray(value)) {
|
|
587
663
|
if (type[Kind] !== 'array') throw `Multiple values found`
|
|
588
|
-
|
|
664
|
+
runCompiled(value, type)
|
|
589
665
|
let pv = []
|
|
590
666
|
let errors: Record<number, any> = {}
|
|
591
667
|
for (let [idx, v] of value.entries()) {
|
|
@@ -607,16 +683,16 @@ const paramParser = (
|
|
|
607
683
|
if (value === null || value === undefined || value === '') throw `Not a valid integer`
|
|
608
684
|
const parsedValue = Number(value)
|
|
609
685
|
if (!Number.isFinite(parsedValue) || !Number.isInteger(parsedValue)) throw `Not a valid integer`
|
|
610
|
-
|
|
686
|
+
runCompiled(parsedValue, type)
|
|
611
687
|
return parsedValue
|
|
612
688
|
} else if (type[Kind] === 'number') {
|
|
613
689
|
if (value === null || value === undefined || value === '') throw `Not a valid number`
|
|
614
690
|
const parsedValue = Number(value)
|
|
615
691
|
if (!Number.isFinite(parsedValue)) throw `Not a valid number`
|
|
616
|
-
|
|
692
|
+
runCompiled(parsedValue, type)
|
|
617
693
|
return parsedValue
|
|
618
694
|
} else if (type[Kind] === 'string') {
|
|
619
|
-
|
|
695
|
+
runCompiled(value, type)
|
|
620
696
|
return value
|
|
621
697
|
} else if (type[Kind] === 'literal') {
|
|
622
698
|
const lit = type as STLiteral
|
|
@@ -632,7 +708,7 @@ const paramParser = (
|
|
|
632
708
|
} catch (e) {
|
|
633
709
|
throw `Not a valid object`
|
|
634
710
|
}
|
|
635
|
-
return
|
|
711
|
+
return runCompiled(json, type)
|
|
636
712
|
} else if (type[Kind] === 'array') {
|
|
637
713
|
return [paramParser(value, (type as STArray).items as STMultipartFormValues) as Static<STPropsValue>]
|
|
638
714
|
} else if (type[Kind] === 'byteArray') {
|
|
@@ -688,6 +764,43 @@ export const requestPathParser = (input: string, path: string) => {
|
|
|
688
764
|
return params
|
|
689
765
|
}
|
|
690
766
|
|
|
767
|
+
/**
|
|
768
|
+
* OpenAPI's `deepObject`: `?filter[lat]=1&filter[lon]=2` is one object
|
|
769
|
+
* parameter. Gathers the bracketed keys belonging to `key` and coerces each
|
|
770
|
+
* value against the property schema governing it — `additionalProperties`
|
|
771
|
+
* included, so a `$T.record` query parameter works too. Returns undefined when
|
|
772
|
+
* the query carries no bracketed key for `key`, leaving the JSON-encoded
|
|
773
|
+
* spelling (`?filter={"lat":1}`) to `paramParser`.
|
|
774
|
+
*/
|
|
775
|
+
const deepObjectParser = (params: { [key: string]: any }, key: string, type: STObject) => {
|
|
776
|
+
const prefix = `${key}[`
|
|
777
|
+
const out: Record<string, any> = {}
|
|
778
|
+
const errors: Record<string, any> = {}
|
|
779
|
+
let found = false
|
|
780
|
+
for (const k of Object.keys(params)) {
|
|
781
|
+
if (!k.startsWith(prefix) || !k.endsWith(']')) continue
|
|
782
|
+
// one level only: OpenAPI leaves nested deepObject undefined
|
|
783
|
+
const prop = k.slice(prefix.length, -1)
|
|
784
|
+
if (!prop || prop.includes('[') || prop.includes(']')) continue
|
|
785
|
+
found = true
|
|
786
|
+
const ps = (type.props?.[prop] ?? type.additionalProperties) as STMultipartFormValues | undefined
|
|
787
|
+
// an undeclared property is kept raw: `additionalProperties: false` rejects
|
|
788
|
+
// it downstream, an open object ignores it, and neither needs a guess here
|
|
789
|
+
if (!ps) {
|
|
790
|
+
out[prop] = params[k]
|
|
791
|
+
continue
|
|
792
|
+
}
|
|
793
|
+
try {
|
|
794
|
+
out[prop] = paramParser(params[k], ps)
|
|
795
|
+
} catch (error) {
|
|
796
|
+
errors[prop] = error
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
if (!found) return undefined
|
|
800
|
+
if (Object.keys(errors).length) throw errors
|
|
801
|
+
return out
|
|
802
|
+
}
|
|
803
|
+
|
|
691
804
|
export const parseEntry = <T extends STProps>(
|
|
692
805
|
params: { [key: string]: any },
|
|
693
806
|
schema: T,
|
|
@@ -696,18 +809,38 @@ export const parseEntry = <T extends STProps>(
|
|
|
696
809
|
const parsedParams: Partial<Static<STObject<T>>> = {}
|
|
697
810
|
const errors: { [key: string]: string | string[] } = {}
|
|
698
811
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
812
|
+
// case-insensitive lookup (headers) without copying the whole map per request:
|
|
813
|
+
// the already-lowercased key usually hits, and the lowercased index — null
|
|
814
|
+
// prototype, the keys are untrusted — is only built when it does not
|
|
815
|
+
let lowercased: Record<string, any> | undefined
|
|
816
|
+
const lookup = (key: string) => {
|
|
817
|
+
const v = params[key]
|
|
818
|
+
if (v !== undefined || options?.i !== true) return v
|
|
819
|
+
if (!lowercased) {
|
|
820
|
+
const index: Record<string, any> = Object.create(null)
|
|
821
|
+
for (const k of Object.keys(params)) index[k.toLowerCase()] = params[k]
|
|
822
|
+
lowercased = index
|
|
823
|
+
}
|
|
824
|
+
return lowercased[key]
|
|
704
825
|
}
|
|
705
826
|
|
|
706
827
|
Object.entries(schema).forEach(([key, s]) => {
|
|
707
828
|
const k = options?.i === true ? key.toLowerCase() : key
|
|
708
|
-
let v =
|
|
709
|
-
if (s[Kind] === 'array' && options?.name === 'query' && typeof v === 'string')
|
|
829
|
+
let v = lookup(k)
|
|
830
|
+
if (s[Kind] === 'array' && options?.name === 'query' && typeof v === 'string') {
|
|
831
|
+
// a single value may carry several items; repeated keys always may too
|
|
832
|
+
const delimiter = (s as unknown as STArray).split
|
|
833
|
+
if (delimiter !== false) v = v.split(delimiter || ',')
|
|
834
|
+
}
|
|
710
835
|
try {
|
|
836
|
+
if (options?.name === 'query' && s[Kind] === 'object' && v === undefined) {
|
|
837
|
+
const deep = deepObjectParser(params, k, s as unknown as STObject)
|
|
838
|
+
if (deep !== undefined) {
|
|
839
|
+
//@ts-ignore
|
|
840
|
+
parsedParams[k] = runCompiled(deep, s as unknown as STSchema)
|
|
841
|
+
return
|
|
842
|
+
}
|
|
843
|
+
}
|
|
711
844
|
let p = paramParser(v, s as STMultipartFormValues)
|
|
712
845
|
//@ts-ignore
|
|
713
846
|
if (p !== undefined) parsedParams[k] = p
|
|
@@ -753,10 +886,9 @@ export const responseParser = (response: any, ctx: Context, cookies: string[], s
|
|
|
753
886
|
}
|
|
754
887
|
for (const cookie of cookies) existing.append('set-cookie', cookie)
|
|
755
888
|
return response
|
|
756
|
-
}
|
|
757
|
-
else if (typeof response === 'string') {
|
|
889
|
+
} else if (typeof response === 'string') {
|
|
758
890
|
if (!details?.headers?.has('content-type')) {
|
|
759
|
-
const statusEntry: any = schema
|
|
891
|
+
const statusEntry: any = responseEntryFor(schema as Partial<Record<string | number, any>>, details.status)
|
|
760
892
|
const isJson = statusEntry?.[Kind]
|
|
761
893
|
? statusEntry[Kind] === 'json'
|
|
762
894
|
: statusEntry?.['application/json'] && !statusEntry?.['text/plain']
|
|
@@ -779,16 +911,27 @@ export const responseParser = (response: any, ctx: Context, cookies: string[], s
|
|
|
779
911
|
const rs = new ReadableStream({
|
|
780
912
|
type: 'direct',
|
|
781
913
|
async pull(controller) {
|
|
782
|
-
|
|
914
|
+
// ids only need to be unique within the stream: one random prefix per
|
|
915
|
+
// connection plus a counter, instead of a CSPRNG call per event
|
|
916
|
+
const prefix = crypto.randomUUID()
|
|
917
|
+
let n = 0
|
|
918
|
+
let id = ctx.request.headers.get('last-event-id') ?? `${prefix}:${n}`
|
|
783
919
|
for await (const r of response) {
|
|
784
|
-
|
|
920
|
+
// multi-line values must be split into one data: field per line (SSE spec)
|
|
921
|
+
let data =
|
|
922
|
+
`id:${id}\n` +
|
|
923
|
+
String(r)
|
|
924
|
+
.split(/\r\n|\r|\n/)
|
|
925
|
+
.map(l => `data:${l}`)
|
|
926
|
+
.join('\n') +
|
|
927
|
+
'\n\n'
|
|
785
928
|
try {
|
|
786
929
|
await controller.write(data)
|
|
787
930
|
await controller.flush()
|
|
788
931
|
} catch (err) {
|
|
789
932
|
console.error(err)
|
|
790
933
|
}
|
|
791
|
-
id =
|
|
934
|
+
id = `${prefix}:${++n}`
|
|
792
935
|
}
|
|
793
936
|
controller.close()
|
|
794
937
|
},
|
|
@@ -810,13 +953,16 @@ export const responseParser = (response: any, ctx: Context, cookies: string[], s
|
|
|
810
953
|
const unionize = (b: any, schema: STUnion) => {
|
|
811
954
|
let res
|
|
812
955
|
let error
|
|
813
|
-
const discriminants = schema.members.reduce(
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
956
|
+
const discriminants = schema.members.reduce(
|
|
957
|
+
(acc, obj) => {
|
|
958
|
+
const props = (obj as STObject).props
|
|
959
|
+
return acc.filter(k => props && k in props && props[k]?.[Kind] === 'literal' && !props[k]?.[Optional])
|
|
960
|
+
},
|
|
961
|
+
Object.keys((schema.members[0] as STObject)?.props || {})
|
|
962
|
+
)
|
|
817
963
|
for (let s of schema.members) {
|
|
818
964
|
try {
|
|
819
|
-
res =
|
|
965
|
+
res = runCompiled(b, s, { parse: true })
|
|
820
966
|
if (res !== undefined) break
|
|
821
967
|
} catch (err: any) {
|
|
822
968
|
if (discriminants.every(d => !err?.[d]?.startsWith('Not a valid value'))) error = err
|
|
@@ -830,7 +976,7 @@ const unionize = (b: any, schema: STUnion) => {
|
|
|
830
976
|
const intersectionize = (b: any, schema: STIntersection<any>) => {
|
|
831
977
|
let res
|
|
832
978
|
try {
|
|
833
|
-
for (let s of schema.allOf) res =
|
|
979
|
+
for (let s of schema.allOf) res = runCompiled(b, s, { parse: true })
|
|
834
980
|
return res
|
|
835
981
|
} catch (e) {
|
|
836
982
|
throw new RequestError({ status: 400, payload: { body: `No matching body schema found` } })
|