@nifrajs/schema 3.5.0 → 4.0.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 +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/openapi.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { ContractShape } from "@nifrajs/core/contract"
|
|
2
2
|
import { type ProjectEvidenceSnapshot, reflectedRoutesFromEvidence } from "@nifrajs/core/evidence"
|
|
3
|
+
import {
|
|
4
|
+
compileRoutePattern,
|
|
5
|
+
expandOptionalParams,
|
|
6
|
+
type ParamConstraint,
|
|
7
|
+
type RoutePatternSegment,
|
|
8
|
+
} from "@nifrajs/core/pattern"
|
|
3
9
|
import {
|
|
4
10
|
type JsonSchema,
|
|
5
11
|
type ReflectedRouteSchema,
|
|
@@ -86,7 +92,7 @@ export interface ToOpenAPIOptions {
|
|
|
86
92
|
|
|
87
93
|
interface OpenAPIParameter {
|
|
88
94
|
readonly name: string
|
|
89
|
-
readonly in: "path" | "query" | "header"
|
|
95
|
+
readonly in: "path" | "query" | "header" | "cookie"
|
|
90
96
|
readonly required: boolean
|
|
91
97
|
readonly schema: JsonSchema
|
|
92
98
|
}
|
|
@@ -181,54 +187,47 @@ class SchemaStore {
|
|
|
181
187
|
}
|
|
182
188
|
}
|
|
183
189
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
/** Names captured by a route segment under Nifra's parameter grammar. */
|
|
188
|
-
function segmentParams(segment: string): readonly string[] {
|
|
189
|
-
if (segment.startsWith("*")) return [segment.length > 1 ? segment.slice(1) : "wildcard"]
|
|
190
|
-
const names: string[] = []
|
|
191
|
-
for (const match of segment.matchAll(MIXED_PARAM)) {
|
|
192
|
-
const name = match[1]
|
|
193
|
-
if (name === undefined || match.index === undefined) continue
|
|
194
|
-
const previous = segment[match.index - 1]
|
|
195
|
-
const atEnd = match.index + match[0].length === segment.length
|
|
196
|
-
// `things:batchGet` is an established literal action path. The router only treats a terminal
|
|
197
|
-
// colon after an identifier as a parameter when a literal suffix follows it.
|
|
198
|
-
if (previous !== undefined && /[A-Za-z0-9_]/.test(previous) && atEnd) continue
|
|
199
|
-
names.push(name)
|
|
200
|
-
}
|
|
201
|
-
return names
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/** Name of a wholly dynamic path segment, or `undefined` for static/mixed segments. */
|
|
205
|
-
function segmentParam(segment: string): string | undefined {
|
|
206
|
-
const names = segmentParams(segment)
|
|
207
|
-
if (segment.startsWith("*") && names.length === 1) return names[0]
|
|
208
|
-
if (segment.startsWith(":") && NIFRA_PARAM.test(segment.slice(1)) && names.length === 1)
|
|
209
|
-
return names[0]
|
|
210
|
-
return undefined
|
|
190
|
+
interface PathParameter {
|
|
191
|
+
readonly name: string
|
|
192
|
+
readonly constraint: ParamConstraint | undefined
|
|
211
193
|
}
|
|
212
194
|
|
|
213
|
-
/**
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
195
|
+
/**
|
|
196
|
+
* `/users/:id/*rest` as `/users/{id}/{rest}`, with its params in order. Split by the router's own
|
|
197
|
+
* compiler, so names and constraints end where the router ends them.
|
|
198
|
+
*/
|
|
199
|
+
function templatedPath(path: string): {
|
|
200
|
+
readonly template: string
|
|
201
|
+
readonly parameters: readonly PathParameter[]
|
|
202
|
+
} {
|
|
203
|
+
const parameters: PathParameter[] = []
|
|
204
|
+
const take = (name: string, constraint?: ParamConstraint): string => {
|
|
205
|
+
parameters.push({ name, constraint })
|
|
221
206
|
return `{${name}}`
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
207
|
+
}
|
|
208
|
+
let segments: readonly RoutePatternSegment[]
|
|
209
|
+
try {
|
|
210
|
+
segments = compileRoutePattern(path).segments
|
|
211
|
+
} catch {
|
|
212
|
+
return { template: path, parameters }
|
|
213
|
+
}
|
|
214
|
+
const template = segments
|
|
215
|
+
.map((segment) =>
|
|
216
|
+
segment.kind === "static"
|
|
217
|
+
? segment.value
|
|
218
|
+
: segment.kind === "param"
|
|
219
|
+
? take(segment.name)
|
|
220
|
+
: segment.kind === "wildcard"
|
|
221
|
+
? take(segment.name === "*" ? "wildcard" : segment.name)
|
|
222
|
+
: segment.parts
|
|
223
|
+
.map((part) => (part.t === "lit" ? part.v : take(part.name, part.c)))
|
|
224
|
+
.join(""),
|
|
225
|
+
)
|
|
226
|
+
.join("/")
|
|
227
|
+
return { template: `/${template}`, parameters }
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
function pathParameters(path: string, paramsSchema?: SchemaReflection): OpenAPIParameter[] {
|
|
231
|
-
const params: OpenAPIParameter[] = []
|
|
232
231
|
// Build a lookup of per-field schemas from the declared params schema (if any).
|
|
233
232
|
const fieldSchemas = new Map<string, JsonSchema>()
|
|
234
233
|
if (paramsSchema?.fields !== undefined) {
|
|
@@ -236,15 +235,20 @@ function pathParameters(path: string, paramsSchema?: SchemaReflection): OpenAPIP
|
|
|
236
235
|
fieldSchemas.set(field.name, field.schema)
|
|
237
236
|
}
|
|
238
237
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
238
|
+
// A declared field schema (uuid format, integer type, etc.) wins. Without one, a constraint in
|
|
239
|
+
// the path says what the router accepts; without either, the parameter is any string.
|
|
240
|
+
return templatedPath(path).parameters.map(({ name, constraint }) => ({
|
|
241
|
+
name,
|
|
242
|
+
in: "path",
|
|
243
|
+
required: true,
|
|
244
|
+
schema:
|
|
245
|
+
fieldSchemas.get(name) ??
|
|
246
|
+
(constraint === undefined
|
|
247
|
+
? { type: "string" }
|
|
248
|
+
: constraint.oneOf === undefined
|
|
249
|
+
? { type: "string", pattern: `^${constraint.source}$` }
|
|
250
|
+
: { type: "string", enum: constraint.oneOf }),
|
|
251
|
+
}))
|
|
248
252
|
}
|
|
249
253
|
|
|
250
254
|
function queryParameters(schema: SchemaReflection | undefined): OpenAPIParameter[] {
|
|
@@ -270,11 +274,23 @@ function headerParameters(schema: SchemaReflection | undefined): OpenAPIParamete
|
|
|
270
274
|
}))
|
|
271
275
|
}
|
|
272
276
|
|
|
277
|
+
/** Cookie names are case-sensitive, so each declared field keeps its name as written. */
|
|
278
|
+
function cookieParameters(schema: SchemaReflection | undefined): OpenAPIParameter[] {
|
|
279
|
+
if (schema?.fields === undefined) return []
|
|
280
|
+
return schema.fields.map((field) => ({
|
|
281
|
+
name: field.name,
|
|
282
|
+
in: "cookie" as const,
|
|
283
|
+
required: field.required,
|
|
284
|
+
schema: field.schema,
|
|
285
|
+
}))
|
|
286
|
+
}
|
|
287
|
+
|
|
273
288
|
interface OperationInput {
|
|
274
289
|
readonly path: string
|
|
275
290
|
readonly body: SchemaReflection | undefined
|
|
276
291
|
readonly query: SchemaReflection | undefined
|
|
277
292
|
readonly headers: SchemaReflection | undefined
|
|
293
|
+
readonly cookies?: SchemaReflection | undefined
|
|
278
294
|
/** Reflected params schema - per-field constraints merge into path parameters. */
|
|
279
295
|
readonly params: SchemaReflection | undefined
|
|
280
296
|
readonly response: SchemaReflection | undefined
|
|
@@ -374,6 +390,30 @@ function buildResponses(
|
|
|
374
390
|
return responses
|
|
375
391
|
}
|
|
376
392
|
|
|
393
|
+
const isBinary = (schema: unknown): boolean =>
|
|
394
|
+
typeof schema === "object" &&
|
|
395
|
+
schema !== null &&
|
|
396
|
+
(schema as { type?: unknown }).type === "string" &&
|
|
397
|
+
(schema as { format?: unknown }).format === "binary"
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Whether a body schema declares a file field (a binary string, or a list of them) - the shape
|
|
401
|
+
* `t.form` produces. JSON cannot carry a file, so such a body is `multipart/form-data`. Read off
|
|
402
|
+
* the plain JSON Schema so a document built from a stored evidence snapshot agrees with a live one.
|
|
403
|
+
*/
|
|
404
|
+
function hasFileField(schema: JsonSchema | undefined): boolean {
|
|
405
|
+
if (typeof schema !== "object" || schema === null) return false
|
|
406
|
+
const properties = (schema as { properties?: unknown }).properties
|
|
407
|
+
if (typeof properties !== "object" || properties === null) return false
|
|
408
|
+
return Object.values(properties).some(
|
|
409
|
+
(property) =>
|
|
410
|
+
isBinary(property) ||
|
|
411
|
+
(typeof property === "object" &&
|
|
412
|
+
property !== null &&
|
|
413
|
+
isBinary((property as { items?: unknown }).items)),
|
|
414
|
+
)
|
|
415
|
+
}
|
|
416
|
+
|
|
377
417
|
function buildOperation(input: OperationInput, store: SchemaStore): OpenAPIOperation {
|
|
378
418
|
const operation: OpenAPIOperation = { responses: buildResponses(input, store) }
|
|
379
419
|
if (input.operationId !== undefined) operation.operationId = input.operationId
|
|
@@ -387,21 +427,36 @@ function buildOperation(input: OperationInput, store: SchemaStore): OpenAPIOpera
|
|
|
387
427
|
...pathParameters(input.path, input.params),
|
|
388
428
|
...queryParameters(input.query),
|
|
389
429
|
...headerParameters(input.headers),
|
|
430
|
+
...cookieParameters(input.cookies),
|
|
390
431
|
]
|
|
391
432
|
if (parameters.length > 0) operation.parameters = parameters
|
|
392
433
|
|
|
393
434
|
if (input.body !== undefined) {
|
|
394
435
|
const schema = store.collect(input.body.jsonSchema)
|
|
395
436
|
if (schema !== undefined) {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
}
|
|
437
|
+
const contentType =
|
|
438
|
+
input.requestContentType ??
|
|
439
|
+
(hasFileField(input.body.jsonSchema) ? "multipart/form-data" : "application/json")
|
|
440
|
+
const content: Record<string, { schema: typeof schema }> = { [contentType]: { schema } }
|
|
441
|
+
// A body schema with a parser of its own reads those media types too: one entry each, in a
|
|
442
|
+
// fixed order so a live document and one built from a stored snapshot are the same document.
|
|
443
|
+
for (const type of [...(input.body.mediaTypes ?? [])].sort()) content[type] ??= { schema }
|
|
444
|
+
operation.requestBody = { required: true, content }
|
|
400
445
|
}
|
|
401
446
|
}
|
|
402
447
|
return operation
|
|
403
448
|
}
|
|
404
449
|
|
|
450
|
+
const PATH_ITEM_METHODS: ReadonlySet<string> = new Set([
|
|
451
|
+
"get",
|
|
452
|
+
"put",
|
|
453
|
+
"post",
|
|
454
|
+
"delete",
|
|
455
|
+
"options",
|
|
456
|
+
"head",
|
|
457
|
+
"patch",
|
|
458
|
+
])
|
|
459
|
+
|
|
405
460
|
function addOperation(
|
|
406
461
|
paths: Record<string, Record<string, OpenAPIOperation>>,
|
|
407
462
|
method: string,
|
|
@@ -409,7 +464,10 @@ function addOperation(
|
|
|
409
464
|
store: SchemaStore,
|
|
410
465
|
operations: ToOpenAPIOptions["operations"],
|
|
411
466
|
): void {
|
|
412
|
-
|
|
467
|
+
// A path item has a field for each standard method and no place for any other, so a route
|
|
468
|
+
// registered under a custom method is left out of the document.
|
|
469
|
+
if (!PATH_ITEM_METHODS.has(method.toLowerCase())) return
|
|
470
|
+
const templated = templatedPath(input.path).template
|
|
413
471
|
const pathItem = paths[templated] ?? {}
|
|
414
472
|
paths[templated] = pathItem
|
|
415
473
|
let operation = buildOperation(input, store)
|
|
@@ -448,6 +506,7 @@ export function toOpenAPI(
|
|
|
448
506
|
body: route.schema?.body,
|
|
449
507
|
query: route.schema?.query,
|
|
450
508
|
headers: route.schema?.headers,
|
|
509
|
+
cookies: route.schema?.cookies,
|
|
451
510
|
params: route.schema?.params,
|
|
452
511
|
// A route may now declare a `response` contract - emit it as the 200 body schema.
|
|
453
512
|
response: route.schema?.response,
|
|
@@ -463,44 +522,54 @@ export function toOpenAPI(
|
|
|
463
522
|
}
|
|
464
523
|
} else {
|
|
465
524
|
for (const [name, op] of Object.entries(input)) {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
525
|
+
// A path ending in optional params is one operation per concrete path it serves. Operation ids
|
|
526
|
+
// are unique in a document, so the contract name goes to the full path and the shorter ones
|
|
527
|
+
// carry none; a shorter path declares only the parameters it has.
|
|
528
|
+
const forms = expandOptionalParams(op.path)
|
|
529
|
+
const responses =
|
|
530
|
+
op.responses === undefined
|
|
531
|
+
? undefined
|
|
532
|
+
: Object.fromEntries(
|
|
533
|
+
Object.entries(op.responses).map(([status, response]) => {
|
|
534
|
+
const { schema, ...metadata } = response
|
|
535
|
+
return [
|
|
536
|
+
status,
|
|
537
|
+
{
|
|
538
|
+
...metadata,
|
|
539
|
+
...(schema === undefined ? {} : { schema: reflectSchema(schema) }),
|
|
540
|
+
},
|
|
541
|
+
]
|
|
542
|
+
}),
|
|
543
|
+
)
|
|
544
|
+
for (const path of forms) {
|
|
545
|
+
addOperation(
|
|
546
|
+
paths,
|
|
547
|
+
op.method,
|
|
548
|
+
{
|
|
549
|
+
path,
|
|
550
|
+
body: op.body === undefined ? undefined : reflectSchema(op.body),
|
|
551
|
+
query: op.query === undefined ? undefined : reflectSchema(op.query),
|
|
552
|
+
headers: op.headers === undefined ? undefined : reflectSchema(op.headers),
|
|
553
|
+
cookies: op.cookies === undefined ? undefined : reflectSchema(op.cookies),
|
|
554
|
+
params: op.params === undefined ? undefined : reflectSchema(op.params),
|
|
555
|
+
response: op.response === undefined ? undefined : reflectSchema(op.response),
|
|
556
|
+
operationId: path === forms[forms.length - 1] ? name : undefined,
|
|
557
|
+
summary: op.summary,
|
|
558
|
+
description: op.description,
|
|
559
|
+
tags: op.tags,
|
|
560
|
+
deprecated: op.deprecated,
|
|
561
|
+
security: op.security,
|
|
562
|
+
requestContentType: op.requestContentType,
|
|
563
|
+
responseContentType: op.responseContentType,
|
|
564
|
+
responses,
|
|
565
|
+
inferredResponses:
|
|
566
|
+
options.inferredResponses?.[`${op.method.toUpperCase()} ${path}`] ??
|
|
567
|
+
options.inferredResponses?.[`${op.method.toUpperCase()} ${op.path}`],
|
|
568
|
+
},
|
|
569
|
+
store,
|
|
570
|
+
options.operations,
|
|
571
|
+
)
|
|
572
|
+
}
|
|
504
573
|
}
|
|
505
574
|
}
|
|
506
575
|
|
package/src/t.ts
CHANGED
|
@@ -1,26 +1,66 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type ArrayOptions,
|
|
3
|
+
CloneType,
|
|
3
4
|
type IntegerOptions,
|
|
5
|
+
Kind,
|
|
4
6
|
type NumberOptions,
|
|
5
7
|
type ObjectOptions,
|
|
8
|
+
type Static,
|
|
6
9
|
type StringOptions,
|
|
10
|
+
type TArray,
|
|
7
11
|
type TLiteralValue,
|
|
8
12
|
type TSchema,
|
|
9
13
|
type TUnion,
|
|
14
|
+
type TUnsafe,
|
|
10
15
|
Type,
|
|
16
|
+
TypeRegistry,
|
|
11
17
|
} from "@sinclair/typebox"
|
|
12
18
|
import { fromTypeBox, type NifraSchema } from "./adapter.ts"
|
|
19
|
+
import { fileOps, refuseFile } from "./file-kind.ts"
|
|
13
20
|
|
|
14
21
|
type Props = Record<string, NifraSchema>
|
|
15
22
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
23
|
+
/** A value `defer()` from `@nifrajs/web` marked to stream in after the page shell. */
|
|
24
|
+
export interface DeferredValue<T> {
|
|
25
|
+
readonly __nifra_deferred: true
|
|
26
|
+
readonly id: number
|
|
27
|
+
readonly promise: Promise<T>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEFERRED_KIND = "NifraDeferred"
|
|
31
|
+
/** Where `@nifrajs/web`'s output guard finds the inner schema's validate. Registered for two copies. */
|
|
32
|
+
const DEFERRED_VALIDATE = Symbol.for("nifra.schema.deferredValidate")
|
|
33
|
+
|
|
34
|
+
const isDeferredValue = (_: unknown, value: unknown): boolean =>
|
|
35
|
+
typeof value === "object" &&
|
|
36
|
+
value !== null &&
|
|
37
|
+
(value as { readonly __nifra_deferred?: unknown }).__nifra_deferred === true &&
|
|
38
|
+
typeof (value as { readonly promise?: { readonly then?: unknown } }).promise?.then === "function"
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Pull each property's raw TypeBox schema out of its `NifraSchema` wrapper. `where` names the calling
|
|
42
|
+
* constructor: these objects are checked as JSON, so a file field is refused here (it belongs in
|
|
43
|
+
* `t.form` from `@nifrajs/schema/form`) instead of failing every request later.
|
|
44
|
+
*/
|
|
45
|
+
function unwrap<P extends Props>(where: string, props: P): { [K in keyof P]: P[K]["jsonSchema"] } {
|
|
18
46
|
const out: Record<string, TSchema> = {}
|
|
19
47
|
// Object.entries → own enumerable string keys only (no prototype walk).
|
|
20
|
-
for (const [key, schema] of Object.entries(props))
|
|
48
|
+
for (const [key, schema] of Object.entries(props)) {
|
|
49
|
+
refuseFile(where, schema.jsonSchema)
|
|
50
|
+
out[key] = schema.jsonSchema
|
|
51
|
+
}
|
|
21
52
|
return out as { [K in keyof P]: P[K]["jsonSchema"] }
|
|
22
53
|
}
|
|
23
54
|
|
|
55
|
+
/** An open object whose scalar fields are coerced from strings - the shape of every request input
|
|
56
|
+
* that arrives as name/value text (a query string, a `Cookie` header). */
|
|
57
|
+
const textFields =
|
|
58
|
+
(where: string) =>
|
|
59
|
+
<P extends Props>(props: P, options?: ObjectOptions) =>
|
|
60
|
+
fromTypeBox(Type.Object(unwrap(where, props), { additionalProperties: true, ...options }), {
|
|
61
|
+
coerce: true,
|
|
62
|
+
})
|
|
63
|
+
|
|
24
64
|
/**
|
|
25
65
|
* The built-in schema builder. Each constructor returns a `NifraSchema` - a
|
|
26
66
|
* Standard Schema whose validated output type flows into `c.body`/`c.query`, and
|
|
@@ -46,16 +86,33 @@ export const t = {
|
|
|
46
86
|
// properties (no mass-assignment). Use `t.looseObject` (or pass `{ additionalProperties: true }`) to
|
|
47
87
|
// opt into an open object; an explicit `options.additionalProperties` always wins over the default.
|
|
48
88
|
object: <P extends Props>(props: P, options?: ObjectOptions) =>
|
|
49
|
-
fromTypeBox(
|
|
89
|
+
fromTypeBox(
|
|
90
|
+
Type.Object(unwrap("t.object", props), { additionalProperties: false, ...options }),
|
|
91
|
+
),
|
|
50
92
|
/** Like `t.object` but ACCEPTS (and passes through) unknown fields - the explicit opt-out of the
|
|
51
93
|
* strict default. Prefer `t.object` unless you genuinely need an open object. */
|
|
52
94
|
looseObject: <P extends Props>(props: P, options?: ObjectOptions) =>
|
|
53
|
-
fromTypeBox(
|
|
54
|
-
|
|
95
|
+
fromTypeBox(
|
|
96
|
+
Type.Object(unwrap("t.looseObject", props), { additionalProperties: true, ...options }),
|
|
97
|
+
),
|
|
98
|
+
/**
|
|
99
|
+
* A list. A list of `t.file()` (from `@nifrajs/schema/form`) honors `minItems` / `maxItems` and is
|
|
100
|
+
* used inside `t.form`.
|
|
101
|
+
*/
|
|
102
|
+
array: <T extends TSchema>(
|
|
103
|
+
item: NifraSchema<T>,
|
|
104
|
+
options?: ArrayOptions,
|
|
105
|
+
): NifraSchema<TArray<T>> =>
|
|
106
|
+
(fileOps("t.array", item)?.array(item, options) as NifraSchema<TArray<T>> | undefined) ??
|
|
55
107
|
fromTypeBox(Type.Array(item.jsonSchema, options)),
|
|
56
|
-
/** Marks a property optional inside `t.object`; standalone it is `T | undefined`. */
|
|
57
|
-
optional: <T extends TSchema>(schema: NifraSchema<T>) =>
|
|
58
|
-
fromTypeBox(Type.Optional(schema.jsonSchema))
|
|
108
|
+
/** Marks a property optional inside `t.object` / `t.form`; standalone it is `T | undefined`. */
|
|
109
|
+
optional: <T extends TSchema>(schema: NifraSchema<T>) => {
|
|
110
|
+
const plain = () => fromTypeBox(Type.Optional(schema.jsonSchema))
|
|
111
|
+
return (
|
|
112
|
+
(fileOps("t.optional", schema)?.optional(schema) as ReturnType<typeof plain> | undefined) ??
|
|
113
|
+
plain()
|
|
114
|
+
)
|
|
115
|
+
},
|
|
59
116
|
// `const S` captures the argument as a tuple; the explicit return type then maps
|
|
60
117
|
// over that captured tuple (`S[K]["jsonSchema"]`) so the union's `Static` is
|
|
61
118
|
// `A | B`, not `unknown` - the value-level `.map` can't preserve per-element
|
|
@@ -63,17 +120,25 @@ export const t = {
|
|
|
63
120
|
// (order/length are preserved by `map`, so the tuple shape is sound).
|
|
64
121
|
union: <const S extends readonly NifraSchema[]>(schemas: S) =>
|
|
65
122
|
fromTypeBox(
|
|
66
|
-
Type.Union(
|
|
123
|
+
Type.Union(
|
|
124
|
+
(schemas as readonly NifraSchema[]).map((schema) => {
|
|
125
|
+
refuseFile("t.union", schema.jsonSchema)
|
|
126
|
+
return schema.jsonSchema
|
|
127
|
+
}),
|
|
128
|
+
),
|
|
67
129
|
) as NifraSchema<TUnion<{ -readonly [K in keyof S]: S[K]["jsonSchema"] }>>,
|
|
68
|
-
record: <T extends TSchema>(value: NifraSchema<T>, options?: ObjectOptions) =>
|
|
69
|
-
|
|
130
|
+
record: <T extends TSchema>(value: NifraSchema<T>, options?: ObjectOptions) => {
|
|
131
|
+
refuseFile("t.record", value.jsonSchema)
|
|
132
|
+
return fromTypeBox(Type.Record(Type.String(), value.jsonSchema, options))
|
|
133
|
+
},
|
|
70
134
|
|
|
71
135
|
// Composed from TypeBox directly (not `t.object`/`t.array`) so the `t` literal doesn't reference
|
|
72
136
|
// itself during inference. Cursor pagination - not OFFSET - is the production default: stable under
|
|
73
137
|
// concurrent inserts and O(1) per page. Build pages with `paginate()` + `encodeCursor`/`decodeCursor`.
|
|
74
138
|
/** A cursor-pagination response envelope: `{ items: T[]; nextCursor: string | null }` (`null` = last page). */
|
|
75
|
-
paginated: <T extends TSchema>(item: NifraSchema<T>, options?: ObjectOptions) =>
|
|
76
|
-
|
|
139
|
+
paginated: <T extends TSchema>(item: NifraSchema<T>, options?: ObjectOptions) => {
|
|
140
|
+
refuseFile("t.paginated", item.jsonSchema)
|
|
141
|
+
return fromTypeBox(
|
|
77
142
|
Type.Object(
|
|
78
143
|
{
|
|
79
144
|
items: Type.Array(item.jsonSchema),
|
|
@@ -81,7 +146,8 @@ export const t = {
|
|
|
81
146
|
},
|
|
82
147
|
{ additionalProperties: false, ...options },
|
|
83
148
|
),
|
|
84
|
-
)
|
|
149
|
+
)
|
|
150
|
+
},
|
|
85
151
|
/** A request query schema for cursor pagination: `{ cursor?: string; limit?: number }`. `maxLimit`
|
|
86
152
|
* caps `limit` - a larger value fails validation (a 422), so a client can't request an unbounded page.
|
|
87
153
|
* `coerce` is on because query values arrive as strings (`?limit=20` → `"20"`); it's what makes `limit`
|
|
@@ -106,8 +172,42 @@ export const t = {
|
|
|
106
172
|
* traffic appends params your schema never declares, causing 422s that never appear in dev/CI). Pass
|
|
107
173
|
* `{ additionalProperties: false }` to enforce a strict allowlist. This is the query-slot constructor;
|
|
108
174
|
* `t.object` stays the constructor for body slots (no coercion, a JSON body is already typed). */
|
|
109
|
-
query:
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
175
|
+
query: textFields("t.query"),
|
|
176
|
+
/** A request-cookie schema for the `cookies` route slot, with the same string->scalar COERCION as
|
|
177
|
+
* `t.query` (cookie values always arrive as strings). **Open by default**: a browser sends every
|
|
178
|
+
* cookie the site has set - analytics, consent, other routes' sessions - so a strict allowlist
|
|
179
|
+
* would 422 real traffic. Declare the cookies the route reads; the rest pass through. */
|
|
180
|
+
cookies: textFields("t.cookies"),
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* A loader value marked with `defer()`, streamed in after the shell. `inner` describes what the
|
|
184
|
+
* promise resolves to; `@nifrajs/web` projects and validates the resolved value by it before it
|
|
185
|
+
* streams, so a deferred value is held to the same output contract as the rest of the data.
|
|
186
|
+
*/
|
|
187
|
+
deferred: <T extends TSchema>(
|
|
188
|
+
inner: NifraSchema<T>,
|
|
189
|
+
): NifraSchema<TUnsafe<DeferredValue<Static<T>>>> => {
|
|
190
|
+
refuseFile("t.deferred", inner.jsonSchema)
|
|
191
|
+
if (!TypeRegistry.Has(DEFERRED_KIND)) TypeRegistry.Set(DEFERRED_KIND, isDeferredValue)
|
|
192
|
+
return fromTypeBox(
|
|
193
|
+
Type.Unsafe<DeferredValue<Static<T>>>({
|
|
194
|
+
[Kind]: DEFERRED_KIND,
|
|
195
|
+
"x-nifra-deferred": true,
|
|
196
|
+
inner: inner.jsonSchema,
|
|
197
|
+
[DEFERRED_VALIDATE]: inner["~standard"].validate,
|
|
198
|
+
}),
|
|
199
|
+
)
|
|
200
|
+
},
|
|
201
|
+
/**
|
|
202
|
+
* Allow a field with a sensitive name (`token`, `password`, `apiKey`, ...) in an output schema.
|
|
203
|
+
* An output schema that declares one without this fails at route load; `reason` records why this
|
|
204
|
+
* value may reach the browser, for the reviewer who finds it later.
|
|
205
|
+
*/
|
|
206
|
+
declassified: <T extends TSchema>(reason: string, schema: NifraSchema<T>): NifraSchema<T> => {
|
|
207
|
+
if (typeof reason !== "string" || reason.trim() === "") {
|
|
208
|
+
throw new TypeError("t.declassified: give the reason this field may reach the browser")
|
|
209
|
+
}
|
|
210
|
+
refuseFile("t.declassified", schema.jsonSchema)
|
|
211
|
+
return fromTypeBox(CloneType(schema.jsonSchema, { "x-nifra-declassified": reason }) as T)
|
|
212
|
+
},
|
|
113
213
|
} as const
|