@nifrajs/schema 3.3.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 +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/openapi-import.d.ts +73 -0
- package/dist/openapi-import.d.ts.map +1 -0
- package/dist/openapi-import.js +302 -0
- package/dist/openapi-import.js.map +1 -0
- package/dist/openapi.d.ts +9 -1
- package/dist/openapi.d.ts.map +1 -1
- package/dist/openapi.js +147 -62
- 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 +17 -2
- package/src/openapi-import.ts +410 -0
- package/src/openapi.ts +186 -69
- package/src/t.ts +119 -19
package/src/openapi.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import type { ContractShape } from "@nifrajs/core/contract"
|
|
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"
|
|
2
9
|
import {
|
|
3
10
|
type JsonSchema,
|
|
4
11
|
type ReflectedRouteSchema,
|
|
@@ -50,6 +57,11 @@ export interface ToOpenAPIOptions {
|
|
|
50
57
|
readonly securitySchemes?: Readonly<Record<string, Record<string, unknown>>>
|
|
51
58
|
/** Document-wide security requirement; a per-operation `security` (incl. `[]` for public) overrides it. */
|
|
52
59
|
readonly security?: readonly SecurityRequirement[]
|
|
60
|
+
/**
|
|
61
|
+
* Reuse a canonical token-only route-evidence pass. This is useful for build artifacts and CLI
|
|
62
|
+
* projections that already evaluated the project; it never supplies runtime validators.
|
|
63
|
+
*/
|
|
64
|
+
readonly evidence?: ProjectEvidenceSnapshot
|
|
53
65
|
/**
|
|
54
66
|
* Per-operation overrides, shallow-merged over the generated operation. Keyed by `operationId`
|
|
55
67
|
* (contract op name) or `"METHOD /path"` (e.g. `"GET /users/:id"`). The escape hatch for detail that
|
|
@@ -80,7 +92,7 @@ export interface ToOpenAPIOptions {
|
|
|
80
92
|
|
|
81
93
|
interface OpenAPIParameter {
|
|
82
94
|
readonly name: string
|
|
83
|
-
readonly in: "path" | "query" | "header"
|
|
95
|
+
readonly in: "path" | "query" | "header" | "cookie"
|
|
84
96
|
readonly required: boolean
|
|
85
97
|
readonly schema: JsonSchema
|
|
86
98
|
}
|
|
@@ -175,26 +187,47 @@ class SchemaStore {
|
|
|
175
187
|
}
|
|
176
188
|
}
|
|
177
189
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (segment.startsWith("*")) return segment.length > 1 ? segment.slice(1) : "wildcard"
|
|
182
|
-
return undefined
|
|
190
|
+
interface PathParameter {
|
|
191
|
+
readonly name: string
|
|
192
|
+
readonly constraint: ParamConstraint | undefined
|
|
183
193
|
}
|
|
184
194
|
|
|
185
|
-
/**
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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 })
|
|
206
|
+
return `{${name}}`
|
|
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
|
+
)
|
|
193
226
|
.join("/")
|
|
227
|
+
return { template: `/${template}`, parameters }
|
|
194
228
|
}
|
|
195
229
|
|
|
196
230
|
function pathParameters(path: string, paramsSchema?: SchemaReflection): OpenAPIParameter[] {
|
|
197
|
-
const params: OpenAPIParameter[] = []
|
|
198
231
|
// Build a lookup of per-field schemas from the declared params schema (if any).
|
|
199
232
|
const fieldSchemas = new Map<string, JsonSchema>()
|
|
200
233
|
if (paramsSchema?.fields !== undefined) {
|
|
@@ -202,16 +235,20 @@ function pathParameters(path: string, paramsSchema?: SchemaReflection): OpenAPIP
|
|
|
202
235
|
fieldSchemas.set(field.name, field.schema)
|
|
203
236
|
}
|
|
204
237
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
+
}))
|
|
215
252
|
}
|
|
216
253
|
|
|
217
254
|
function queryParameters(schema: SchemaReflection | undefined): OpenAPIParameter[] {
|
|
@@ -237,11 +274,23 @@ function headerParameters(schema: SchemaReflection | undefined): OpenAPIParamete
|
|
|
237
274
|
}))
|
|
238
275
|
}
|
|
239
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
|
+
|
|
240
288
|
interface OperationInput {
|
|
241
289
|
readonly path: string
|
|
242
290
|
readonly body: SchemaReflection | undefined
|
|
243
291
|
readonly query: SchemaReflection | undefined
|
|
244
292
|
readonly headers: SchemaReflection | undefined
|
|
293
|
+
readonly cookies?: SchemaReflection | undefined
|
|
245
294
|
/** Reflected params schema - per-field constraints merge into path parameters. */
|
|
246
295
|
readonly params: SchemaReflection | undefined
|
|
247
296
|
readonly response: SchemaReflection | undefined
|
|
@@ -341,6 +390,30 @@ function buildResponses(
|
|
|
341
390
|
return responses
|
|
342
391
|
}
|
|
343
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
|
+
|
|
344
417
|
function buildOperation(input: OperationInput, store: SchemaStore): OpenAPIOperation {
|
|
345
418
|
const operation: OpenAPIOperation = { responses: buildResponses(input, store) }
|
|
346
419
|
if (input.operationId !== undefined) operation.operationId = input.operationId
|
|
@@ -354,21 +427,36 @@ function buildOperation(input: OperationInput, store: SchemaStore): OpenAPIOpera
|
|
|
354
427
|
...pathParameters(input.path, input.params),
|
|
355
428
|
...queryParameters(input.query),
|
|
356
429
|
...headerParameters(input.headers),
|
|
430
|
+
...cookieParameters(input.cookies),
|
|
357
431
|
]
|
|
358
432
|
if (parameters.length > 0) operation.parameters = parameters
|
|
359
433
|
|
|
360
434
|
if (input.body !== undefined) {
|
|
361
435
|
const schema = store.collect(input.body.jsonSchema)
|
|
362
436
|
if (schema !== undefined) {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
}
|
|
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 }
|
|
367
445
|
}
|
|
368
446
|
}
|
|
369
447
|
return operation
|
|
370
448
|
}
|
|
371
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
|
+
|
|
372
460
|
function addOperation(
|
|
373
461
|
paths: Record<string, Record<string, OpenAPIOperation>>,
|
|
374
462
|
method: string,
|
|
@@ -376,7 +464,10 @@ function addOperation(
|
|
|
376
464
|
store: SchemaStore,
|
|
377
465
|
operations: ToOpenAPIOptions["operations"],
|
|
378
466
|
): void {
|
|
379
|
-
|
|
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
|
|
380
471
|
const pathItem = paths[templated] ?? {}
|
|
381
472
|
paths[templated] = pathItem
|
|
382
473
|
let operation = buildOperation(input, store)
|
|
@@ -402,7 +493,11 @@ export function toOpenAPI(
|
|
|
402
493
|
const store = new SchemaStore()
|
|
403
494
|
|
|
404
495
|
if (isApp(input)) {
|
|
405
|
-
|
|
496
|
+
const routes =
|
|
497
|
+
options.evidence === undefined
|
|
498
|
+
? reflectRoutes(input)
|
|
499
|
+
: reflectedRoutesFromEvidence(options.evidence)
|
|
500
|
+
for (const route of routes) {
|
|
406
501
|
addOperation(
|
|
407
502
|
paths,
|
|
408
503
|
route.method,
|
|
@@ -411,6 +506,7 @@ export function toOpenAPI(
|
|
|
411
506
|
body: route.schema?.body,
|
|
412
507
|
query: route.schema?.query,
|
|
413
508
|
headers: route.schema?.headers,
|
|
509
|
+
cookies: route.schema?.cookies,
|
|
414
510
|
params: route.schema?.params,
|
|
415
511
|
// A route may now declare a `response` contract - emit it as the 200 body schema.
|
|
416
512
|
response: route.schema?.response,
|
|
@@ -426,44 +522,54 @@ export function toOpenAPI(
|
|
|
426
522
|
}
|
|
427
523
|
} else {
|
|
428
524
|
for (const [name, op] of Object.entries(input)) {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
+
}
|
|
467
573
|
}
|
|
468
574
|
}
|
|
469
575
|
|
|
@@ -485,3 +591,14 @@ export function toOpenAPI(
|
|
|
485
591
|
...(Object.keys(components).length > 0 ? { components } : {}),
|
|
486
592
|
}
|
|
487
593
|
}
|
|
594
|
+
|
|
595
|
+
/** Generate OpenAPI from an existing canonical project-evidence snapshot without loading a server. */
|
|
596
|
+
export function toOpenAPIFromEvidence(
|
|
597
|
+
evidence: ProjectEvidenceSnapshot,
|
|
598
|
+
options: Omit<ToOpenAPIOptions, "evidence"> = {},
|
|
599
|
+
): OpenAPIDocument {
|
|
600
|
+
const app = {
|
|
601
|
+
routes: () => reflectedRoutesFromEvidence(evidence),
|
|
602
|
+
} as unknown as Server
|
|
603
|
+
return toOpenAPI(app, { ...options, evidence })
|
|
604
|
+
}
|
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
|