@nifrajs/schema 3.5.0 → 4.0.2
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 +47 -0
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +14 -2
- package/dist/adapter.js.map +1 -1
- package/dist/file-kind.d.ts +27 -0
- package/dist/file-kind.d.ts.map +1 -0
- package/dist/file-kind.js +35 -0
- package/dist/file-kind.js.map +1 -0
- package/dist/form.d.ts +119 -0
- package/dist/form.d.ts.map +1 -0
- package/dist/form.js +301 -0
- package/dist/form.js.map +1 -0
- package/dist/formats.d.ts.map +1 -1
- package/dist/formats.js +3 -1
- package/dist/formats.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/openapi.d.ts +1 -1
- package/dist/openapi.d.ts.map +1 -1
- package/dist/openapi.js +135 -89
- package/dist/openapi.js.map +1 -1
- package/dist/t.d.ts +31 -4
- package/dist/t.d.ts.map +1 -1
- package/dist/t.js +86 -18
- package/dist/t.js.map +1 -1
- package/package.json +11 -2
- package/src/adapter.ts +17 -2
- package/src/file-kind.ts +48 -0
- package/src/form.ts +394 -0
- package/src/formats.ts +3 -1
- package/src/index.ts +1 -1
- package/src/openapi.ts +164 -95
- package/src/t.ts +119 -19
package/src/form.ts
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/schema/form` - `t` with file fields and `multipart/form-data` bodies.
|
|
3
|
+
*
|
|
4
|
+
* The `t` exported here is the builder from `@nifrajs/schema` plus `t.file` and `t.form`. A file is
|
|
5
|
+
* not JSON, so TypeBox cannot check one: `t.file` carries its own validator and marks its JSON
|
|
6
|
+
* Schema (`{ type: "string", format: "binary" }`) so the other constructors can tell a file field
|
|
7
|
+
* apart. `t.form` is the object constructor that understands those fields - it validates the text
|
|
8
|
+
* fields through TypeBox (with coercion, since every form value arrives as a string) and each file
|
|
9
|
+
* field through its own validator - and it reads the multipart body itself, so it is a route's
|
|
10
|
+
* `body` as is:
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* import { t } from "@nifrajs/schema/form"
|
|
14
|
+
*
|
|
15
|
+
* app.post(
|
|
16
|
+
* "/avatars",
|
|
17
|
+
* {
|
|
18
|
+
* body: t.form({
|
|
19
|
+
* avatar: t.file({ maxBytes: 5_000_000, accept: ["image/png", "image/jpeg"] }),
|
|
20
|
+
* caption: t.optional(t.string({ maxLength: 200 })),
|
|
21
|
+
* }),
|
|
22
|
+
* bodyLimit: 6_000_000,
|
|
23
|
+
* },
|
|
24
|
+
* (c) => ({ name: c.body.avatar.name, type: c.body.avatar.type }),
|
|
25
|
+
* )
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* A subpath on purpose: an app that takes no uploads carries none of this.
|
|
29
|
+
*/
|
|
30
|
+
import { type MultipartLimits, multipartBody } from "@nifrajs/core/multipart"
|
|
31
|
+
import type { StandardIssue, StandardResult, StandardTypes } from "@nifrajs/core/schema"
|
|
32
|
+
import {
|
|
33
|
+
DETECTABLE_MIME_TYPES,
|
|
34
|
+
detectFileType,
|
|
35
|
+
FILE_TYPE_PREFIX_BYTES,
|
|
36
|
+
} from "@nifrajs/uploads/detect"
|
|
37
|
+
import {
|
|
38
|
+
type ArrayOptions,
|
|
39
|
+
Kind,
|
|
40
|
+
OptionalKind,
|
|
41
|
+
type Static,
|
|
42
|
+
type TObject,
|
|
43
|
+
type TSchema,
|
|
44
|
+
type TUnsafe,
|
|
45
|
+
Type,
|
|
46
|
+
} from "@sinclair/typebox"
|
|
47
|
+
import { fromTypeBox, type NifraSchema } from "./adapter.ts"
|
|
48
|
+
import { FILE, FILE_OPS, type FileOps, isFileSchema } from "./file-kind.ts"
|
|
49
|
+
import { t as base } from "./t.ts"
|
|
50
|
+
|
|
51
|
+
type Validate = (value: unknown) => StandardResult<unknown> | Promise<StandardResult<unknown>>
|
|
52
|
+
|
|
53
|
+
export interface FileOptions {
|
|
54
|
+
/** Largest accepted size in bytes. Omit for no bound beyond the route's `bodyLimit`. */
|
|
55
|
+
readonly maxBytes?: number
|
|
56
|
+
/**
|
|
57
|
+
* Accepted types, matched against the file's leading bytes - never against the type the client
|
|
58
|
+
* claimed. Each entry is an exact type (`"image/png"`) or a subtype wildcard (`"image/*"`), and has
|
|
59
|
+
* to be one the bytes can prove: a type with no signature (`text/csv`, `image/svg+xml`) or a
|
|
60
|
+
* catch-all wildcard throws here rather than rejecting every upload later. With `accept` set, the
|
|
61
|
+
* validated file's `type` is the detected one.
|
|
62
|
+
*/
|
|
63
|
+
readonly accept?: readonly string[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The multipart limits (`maxFields`, `maxFiles`, `maxFieldBytes`, `maxFileBytes`) bound what the body
|
|
68
|
+
* may carry before any of it is validated; a request over one is answered `413`.
|
|
69
|
+
*/
|
|
70
|
+
export interface FormOptions extends MultipartLimits {
|
|
71
|
+
/** Accept fields the form does not declare. Defaults to `false`: an undeclared field fails validation. */
|
|
72
|
+
readonly additionalProperties?: boolean
|
|
73
|
+
readonly title?: string
|
|
74
|
+
readonly description?: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const fail = (message: string): StandardResult<never> => ({ issues: [{ message }] })
|
|
78
|
+
|
|
79
|
+
const matches = (mime: string, pattern: string): boolean =>
|
|
80
|
+
pattern === mime || (pattern.endsWith("/*") && mime.startsWith(pattern.slice(0, -1)))
|
|
81
|
+
|
|
82
|
+
function custom<T extends TSchema>(jsonSchema: T, validate: Validate): NifraSchema<T> {
|
|
83
|
+
return {
|
|
84
|
+
"~standard": {
|
|
85
|
+
version: 1,
|
|
86
|
+
vendor: "nifra",
|
|
87
|
+
validate: validate as NifraSchema<T>["~standard"]["validate"],
|
|
88
|
+
// Phantom, as in `fromTypeBox`: read by `InferOutput` at compile time only.
|
|
89
|
+
types: undefined as unknown as StandardTypes<Static<T>, Static<T>>,
|
|
90
|
+
},
|
|
91
|
+
jsonSchema,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A file schema: `t.array` and `t.optional` find their file-aware versions on it. */
|
|
96
|
+
function fileKind<T extends TSchema>(jsonSchema: T, validate: Validate): NifraSchema<T> {
|
|
97
|
+
return Object.defineProperty(custom(jsonSchema, validate), FILE_OPS, { value: OPS })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function assertCount(value: number | undefined, name: string): void {
|
|
101
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) {
|
|
102
|
+
throw new RangeError(`${name} must be a non-negative safe integer`)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function acceptList(accept: readonly string[]): readonly string[] {
|
|
107
|
+
if (!Array.isArray(accept) || accept.length === 0) {
|
|
108
|
+
throw new TypeError("t.file: accept must list at least one type")
|
|
109
|
+
}
|
|
110
|
+
for (const pattern of accept) {
|
|
111
|
+
if (
|
|
112
|
+
typeof pattern !== "string" ||
|
|
113
|
+
!DETECTABLE_MIME_TYPES.some((mime) => matches(mime, pattern))
|
|
114
|
+
) {
|
|
115
|
+
throw new TypeError(
|
|
116
|
+
`t.file: accept ${JSON.stringify(pattern)} cannot be checked against a file's bytes. ` +
|
|
117
|
+
`Use one of ${DETECTABLE_MIME_TYPES.join(", ")}, or a wildcard over them such as "image/*"`,
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return [...accept]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The same file under a different `type`. The result is verified rather than trusted: a runtime
|
|
126
|
+
* that ignores the requested type must not hand the handler a file still labelled by the client.
|
|
127
|
+
*/
|
|
128
|
+
function typedFile(file: File, type: string): File {
|
|
129
|
+
const options: FilePropertyBag = { type, lastModified: file.lastModified }
|
|
130
|
+
let typed = new File([file], file.name, options)
|
|
131
|
+
if (typed.name !== file.name || typed.type !== type) {
|
|
132
|
+
typed = new File([file, ""], file.name, options)
|
|
133
|
+
}
|
|
134
|
+
if (typed.name !== file.name || typed.type !== type || typed.size !== file.size) {
|
|
135
|
+
throw new Error("t.file: this runtime did not apply a file type")
|
|
136
|
+
}
|
|
137
|
+
return typed
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The part of a file the signature check reads, named structurally: the global `Blob` does not
|
|
142
|
+
* declare `slice` under every lib config.
|
|
143
|
+
*/
|
|
144
|
+
type Sliceable = { slice(start: number, end: number): { arrayBuffer(): Promise<ArrayBuffer> } }
|
|
145
|
+
|
|
146
|
+
async function sniff(file: File, accept: readonly string[]): Promise<StandardResult<File>> {
|
|
147
|
+
// Only the leading bytes are read, whatever the file's size.
|
|
148
|
+
const source = file as unknown as Sliceable
|
|
149
|
+
const head = new Uint8Array(await source.slice(0, FILE_TYPE_PREFIX_BYTES).arrayBuffer())
|
|
150
|
+
const detected = detectFileType(head)
|
|
151
|
+
if (detected === null) return fail("File type could not be recognized")
|
|
152
|
+
if (!accept.some((pattern) => matches(detected.mime, pattern))) {
|
|
153
|
+
return fail(`File type ${detected.mime} is not accepted`)
|
|
154
|
+
}
|
|
155
|
+
return { value: file.type === detected.mime ? file : typedFile(file, detected.mime) }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function file(options: FileOptions = {}): NifraSchema<TUnsafe<File>> {
|
|
159
|
+
const { maxBytes } = options
|
|
160
|
+
assertCount(maxBytes, "t.file: maxBytes")
|
|
161
|
+
const accept = options.accept === undefined ? undefined : acceptList(options.accept)
|
|
162
|
+
return fileKind(
|
|
163
|
+
Type.Unsafe<File>({ type: "string", format: "binary", [FILE]: true }),
|
|
164
|
+
(value) => {
|
|
165
|
+
if (!(value instanceof File)) return fail("Expected a file")
|
|
166
|
+
if (maxBytes !== undefined && value.size > maxBytes) {
|
|
167
|
+
return fail(`File is larger than ${maxBytes} bytes`)
|
|
168
|
+
}
|
|
169
|
+
// Reading bytes is the only asynchronous step, so a file with no `accept` validates in place.
|
|
170
|
+
return accept === undefined ? { value } : sniff(value, accept)
|
|
171
|
+
},
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function optionalFile(schema: NifraSchema): NifraSchema {
|
|
176
|
+
const inner: Validate = schema["~standard"].validate
|
|
177
|
+
return fileKind(Type.Optional(schema.jsonSchema), (value) =>
|
|
178
|
+
value === undefined ? { value } : inner(value),
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Run one validator per entry; synchronous unless an entry has bytes to read. */
|
|
183
|
+
function validateEach(
|
|
184
|
+
count: number,
|
|
185
|
+
run: (index: number) => StandardResult<unknown> | Promise<StandardResult<unknown>>,
|
|
186
|
+
path: (index: number) => PropertyKey,
|
|
187
|
+
issues: StandardIssue[],
|
|
188
|
+
keep: (index: number, value: unknown) => void,
|
|
189
|
+
): undefined | Promise<void> {
|
|
190
|
+
// Results are gathered by position and reported in order, so the issue list does not depend on
|
|
191
|
+
// which read finished first.
|
|
192
|
+
const results = new Array<StandardResult<unknown>>(count)
|
|
193
|
+
const pending: Array<Promise<void>> = []
|
|
194
|
+
for (let index = 0; index < count; index++) {
|
|
195
|
+
const result = run(index)
|
|
196
|
+
if (result instanceof Promise) {
|
|
197
|
+
pending.push(
|
|
198
|
+
result.then((settled) => {
|
|
199
|
+
results[index] = settled
|
|
200
|
+
}),
|
|
201
|
+
)
|
|
202
|
+
} else {
|
|
203
|
+
results[index] = result
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const report = (): void => {
|
|
207
|
+
for (let index = 0; index < count; index++) {
|
|
208
|
+
const result = results[index] as StandardResult<unknown>
|
|
209
|
+
if (result.issues === undefined) {
|
|
210
|
+
keep(index, result.value)
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
for (const issue of result.issues) {
|
|
214
|
+
issues.push({ message: issue.message, path: [path(index), ...(issue.path ?? [])] })
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (pending.length === 0) {
|
|
219
|
+
report()
|
|
220
|
+
return undefined
|
|
221
|
+
}
|
|
222
|
+
return Promise.all(pending).then(report)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const UNSUPPORTED_ARRAY_OPTIONS = ["uniqueItems", "contains", "minContains", "maxContains"] as const
|
|
226
|
+
|
|
227
|
+
function fileArray(item: NifraSchema, options?: ArrayOptions): NifraSchema {
|
|
228
|
+
if ((item.jsonSchema as { readonly [FILE]?: true })[FILE] !== true) {
|
|
229
|
+
throw new TypeError("t.array: a list of files cannot be nested inside another list")
|
|
230
|
+
}
|
|
231
|
+
for (const name of UNSUPPORTED_ARRAY_OPTIONS) {
|
|
232
|
+
if (options?.[name] !== undefined) {
|
|
233
|
+
throw new TypeError(`t.array: ${name} is not supported for a list of files`)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const minItems = options?.minItems
|
|
237
|
+
const maxItems = options?.maxItems
|
|
238
|
+
assertCount(minItems, "t.array: minItems")
|
|
239
|
+
assertCount(maxItems, "t.array: maxItems")
|
|
240
|
+
const inner: Validate = item["~standard"].validate
|
|
241
|
+
return fileKind(Type.Array(item.jsonSchema, options), (value) => {
|
|
242
|
+
if (!Array.isArray(value)) return fail("Expected a list of files")
|
|
243
|
+
if (minItems !== undefined && value.length < minItems) {
|
|
244
|
+
return fail(`Expected at least ${minItems} file(s)`)
|
|
245
|
+
}
|
|
246
|
+
if (maxItems !== undefined && value.length > maxItems) {
|
|
247
|
+
return fail(`Expected at most ${maxItems} file(s)`)
|
|
248
|
+
}
|
|
249
|
+
const issues: StandardIssue[] = []
|
|
250
|
+
const output = new Array<unknown>(value.length)
|
|
251
|
+
const finish = (): StandardResult<unknown> =>
|
|
252
|
+
issues.length > 0 ? { issues } : { value: output }
|
|
253
|
+
const pending = validateEach(
|
|
254
|
+
value.length,
|
|
255
|
+
(index) => inner(value[index]),
|
|
256
|
+
(index) => index,
|
|
257
|
+
issues,
|
|
258
|
+
(index, entry) => {
|
|
259
|
+
output[index] = entry
|
|
260
|
+
},
|
|
261
|
+
)
|
|
262
|
+
return pending === undefined ? finish() : pending.then(finish)
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Function declarations, so the table can sit above the constructors that hand it out.
|
|
267
|
+
const OPS: FileOps = { optional: optionalFile, array: fileArray }
|
|
268
|
+
|
|
269
|
+
type Props = Record<string, NifraSchema>
|
|
270
|
+
type FormShape<P extends Props> = TObject<{ [K in keyof P]: P[K]["jsonSchema"] }>
|
|
271
|
+
|
|
272
|
+
interface FileField {
|
|
273
|
+
readonly key: string
|
|
274
|
+
readonly validate: Validate
|
|
275
|
+
readonly list: boolean
|
|
276
|
+
readonly required: boolean
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* What a file field was sent, with the inputs nobody filled in taken out. A browser submits such an
|
|
281
|
+
* input as an empty file, which never reaches a schema; some clients send an empty text part in its
|
|
282
|
+
* place, and that names no file either.
|
|
283
|
+
*/
|
|
284
|
+
function given(entry: unknown): unknown {
|
|
285
|
+
if (entry === "") return undefined
|
|
286
|
+
if (!Array.isArray(entry) || !entry.includes("")) return entry
|
|
287
|
+
const filled = entry.filter((value) => value !== "")
|
|
288
|
+
return filled.length === 0 ? undefined : filled
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function form<P extends Props>(props: P, options?: FormOptions): NifraSchema<FormShape<P>> {
|
|
292
|
+
const files: FileField[] = []
|
|
293
|
+
const fileKeys = new Set<string>()
|
|
294
|
+
/** Required text lists: a list nobody filled in is sent as no field at all, which means empty. */
|
|
295
|
+
const lists: string[] = []
|
|
296
|
+
const text: Record<string, TSchema> = {}
|
|
297
|
+
const all: Record<string, TSchema> = {}
|
|
298
|
+
for (const [key, schema] of Object.entries(props)) {
|
|
299
|
+
// Assigning this key to an ordinary object would replace its prototype instead of adding a field.
|
|
300
|
+
if (key === "__proto__") throw new TypeError('t.form: "__proto__" cannot be a field name')
|
|
301
|
+
const node = schema.jsonSchema
|
|
302
|
+
const marks = node as unknown as { readonly [Kind]?: string; readonly [OptionalKind]?: string }
|
|
303
|
+
const list = marks[Kind] === "Array"
|
|
304
|
+
const required = marks[OptionalKind] !== "Optional"
|
|
305
|
+
all[key] = node
|
|
306
|
+
if (isFileSchema(node)) {
|
|
307
|
+
files.push({ key, validate: schema["~standard"].validate, list, required })
|
|
308
|
+
fileKeys.add(key)
|
|
309
|
+
} else {
|
|
310
|
+
text[key] = node
|
|
311
|
+
if (list && required) lists.push(key)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const objectOptions = {
|
|
315
|
+
...(options?.title === undefined ? {} : { title: options.title }),
|
|
316
|
+
...(options?.description === undefined ? {} : { description: options.description }),
|
|
317
|
+
additionalProperties: options?.additionalProperties === true,
|
|
318
|
+
}
|
|
319
|
+
// `fromTypeBox` validates synchronously; only a file with bytes to read makes a form asynchronous.
|
|
320
|
+
const validateText = fromTypeBox(Type.Object(text, objectOptions), { coerce: true })["~standard"]
|
|
321
|
+
.validate as (value: unknown) => StandardResult<Record<string, unknown>>
|
|
322
|
+
|
|
323
|
+
const validate: Validate = (value) => {
|
|
324
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
325
|
+
return fail("Expected form fields")
|
|
326
|
+
}
|
|
327
|
+
const input = value as Record<string, unknown>
|
|
328
|
+
// Validated on a copy with no prototype: coercion writes into the object it is given, and a
|
|
329
|
+
// field named like an `Object.prototype` member has to be an ordinary field here.
|
|
330
|
+
const fields: Record<string, unknown> = Object.create(null)
|
|
331
|
+
for (const key of Object.keys(input)) {
|
|
332
|
+
if (!fileKeys.has(key)) fields[key] = input[key]
|
|
333
|
+
}
|
|
334
|
+
for (const key of lists) {
|
|
335
|
+
if (!(key in fields)) fields[key] = []
|
|
336
|
+
}
|
|
337
|
+
const checked = validateText(fields)
|
|
338
|
+
const issues: StandardIssue[] = checked.issues === undefined ? [] : [...checked.issues]
|
|
339
|
+
const output: Record<string, unknown> = Object.create(null)
|
|
340
|
+
if (checked.issues === undefined) {
|
|
341
|
+
for (const key of Object.keys(checked.value)) output[key] = checked.value[key]
|
|
342
|
+
}
|
|
343
|
+
const finish = (): StandardResult<unknown> =>
|
|
344
|
+
issues.length > 0 ? { issues } : { value: output }
|
|
345
|
+
const pending = validateEach(
|
|
346
|
+
files.length,
|
|
347
|
+
(index) => {
|
|
348
|
+
const field = files[index] as FileField
|
|
349
|
+
const entry = Object.hasOwn(input, field.key) ? given(input[field.key]) : undefined
|
|
350
|
+
if (!field.list) return field.validate(entry)
|
|
351
|
+
// One file under a repeatable name is a list of one; none at all is an empty list.
|
|
352
|
+
if (entry === undefined) return field.validate(field.required ? [] : undefined)
|
|
353
|
+
return field.validate(Array.isArray(entry) ? entry : [entry])
|
|
354
|
+
},
|
|
355
|
+
(index) => (files[index] as FileField).key,
|
|
356
|
+
issues,
|
|
357
|
+
(index, entry) => {
|
|
358
|
+
if (entry !== undefined) output[(files[index] as FileField).key] = entry
|
|
359
|
+
},
|
|
360
|
+
)
|
|
361
|
+
return pending === undefined ? finish() : pending.then(finish)
|
|
362
|
+
}
|
|
363
|
+
// Branded here, so the schema is a route's `body` as is: a form that did not read multipart
|
|
364
|
+
// bodies would refuse every request it exists to accept.
|
|
365
|
+
return multipartBody(
|
|
366
|
+
custom(Type.Object(all, objectOptions) as unknown as FormShape<P>, validate),
|
|
367
|
+
options,
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** `t` from `@nifrajs/schema`, plus the two constructors a `multipart/form-data` body needs. */
|
|
372
|
+
export const t = {
|
|
373
|
+
...base,
|
|
374
|
+
/**
|
|
375
|
+
* An uploaded file, for a field of `t.form`. `maxBytes` bounds its size; `accept` allow-lists its
|
|
376
|
+
* type by the file's leading bytes (not the type the client claimed) and makes the validated
|
|
377
|
+
* file's `type` the detected one. Without `accept`, `file.type` is whatever the client sent.
|
|
378
|
+
* `file.name` is client-chosen either way - store under a key you generate.
|
|
379
|
+
*
|
|
380
|
+
* Wrap it in `t.optional()` for a file that may be absent, or `t.array()` for several under one
|
|
381
|
+
* name.
|
|
382
|
+
*/
|
|
383
|
+
file,
|
|
384
|
+
/**
|
|
385
|
+
* A `multipart/form-data` body: text fields and `t.file()` fields side by side. Text values are
|
|
386
|
+
* coerced (`"20"` -> `20`) because a form sends only strings, a name sent once satisfies a list
|
|
387
|
+
* field, and a required list nobody filled in is empty rather than missing. Undeclared fields are
|
|
388
|
+
* rejected unless `additionalProperties: true`.
|
|
389
|
+
*
|
|
390
|
+
* The result reads the multipart body itself - pass it as a route's `body`. JSON and urlencoded
|
|
391
|
+
* bodies still reach it, so a form with no required file accepts those too.
|
|
392
|
+
*/
|
|
393
|
+
form,
|
|
394
|
+
}
|
package/src/formats.ts
CHANGED
|
@@ -14,7 +14,9 @@ import { FormatRegistry } from "@sinclair/typebox"
|
|
|
14
14
|
* that registered its own validator first wins.
|
|
15
15
|
*/
|
|
16
16
|
const DEFAULT_FORMATS: Readonly<Record<string, RegExp>> = {
|
|
17
|
-
|
|
17
|
+
// Dot-separated labels that cannot contain a dot: linear on any input (an overlapping `[^\s@]+\.`
|
|
18
|
+
// backtracks quadratically on a long run of dots).
|
|
19
|
+
email: /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/,
|
|
18
20
|
uuid: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
|
19
21
|
"date-time": /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})$/,
|
|
20
22
|
date: /^\d{4}-\d{2}-\d{2}$/,
|
package/src/index.ts
CHANGED
|
@@ -29,4 +29,4 @@ export {
|
|
|
29
29
|
} from "./openapi-import.ts"
|
|
30
30
|
// Cursor-pagination runtime helpers - pair with `t.paginated` / `t.pageQuery`.
|
|
31
31
|
export { decodeCursor, encodeCursor, type Page, paginate } from "./pagination.ts"
|
|
32
|
-
export { t } from "./t.ts"
|
|
32
|
+
export { type DeferredValue, t } from "./t.ts"
|