@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/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
- email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
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"