@nifrajs/schema 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +50 -0
- package/dist/adapter.d.ts +33 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +66 -0
- package/dist/adapter.js.map +1 -0
- package/dist/formats.d.ts +3 -0
- package/dist/formats.d.ts.map +1 -0
- package/dist/formats.js +32 -0
- package/dist/formats.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/openapi.d.ts +92 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +207 -0
- package/dist/openapi.js.map +1 -0
- package/dist/t.d.ts +34 -0
- package/dist/t.d.ts.map +1 -0
- package/dist/t.js +49 -0
- package/dist/t.js.map +1 -0
- package/package.json +69 -0
- package/src/adapter.ts +78 -0
- package/src/formats.ts +33 -0
- package/src/index.ts +14 -0
- package/src/openapi.ts +358 -0
- package/src/t.ts +70 -0
package/dist/t.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Type, } from "@sinclair/typebox";
|
|
2
|
+
import { fromTypeBox } from "./adapter.js";
|
|
3
|
+
/** Pull each property's raw TypeBox schema out of its `NifraSchema` wrapper. */
|
|
4
|
+
function unwrap(props) {
|
|
5
|
+
const out = {};
|
|
6
|
+
// Object.entries → own enumerable string keys only (no prototype walk).
|
|
7
|
+
for (const [key, schema] of Object.entries(props))
|
|
8
|
+
out[key] = schema.jsonSchema;
|
|
9
|
+
return out;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The built-in schema builder. Each constructor returns a `NifraSchema` — a
|
|
13
|
+
* Standard Schema whose validated output type flows into `c.body`/`c.query`, and
|
|
14
|
+
* whose `jsonSchema` powers `toOpenAPI`. Options (min/max, length, pattern, …)
|
|
15
|
+
* pass straight through to TypeBox and so become JSON Schema constraints.
|
|
16
|
+
*
|
|
17
|
+
* Composite constructors are generic over the *inner* TypeBox schema `T extends
|
|
18
|
+
* TSchema` (not over `NifraSchema`): reading `.jsonSchema` off a value typed only as
|
|
19
|
+
* `NifraSchema` would erase to the `TSchema` constraint and collapse the output type
|
|
20
|
+
* to `unknown`. Capturing `T` keeps `Static<T>` precise (`string[]`, not
|
|
21
|
+
* `unknown[]`).
|
|
22
|
+
*/
|
|
23
|
+
export const t = {
|
|
24
|
+
string: (options) => fromTypeBox(Type.String(options)),
|
|
25
|
+
number: (options) => fromTypeBox(Type.Number(options)),
|
|
26
|
+
integer: (options) => fromTypeBox(Type.Integer(options)),
|
|
27
|
+
boolean: () => fromTypeBox(Type.Boolean()),
|
|
28
|
+
null: () => fromTypeBox(Type.Null()),
|
|
29
|
+
literal: (value) => fromTypeBox(Type.Literal(value)),
|
|
30
|
+
// `t.object` REJECTS unknown fields by default (`additionalProperties: false`) — the trust-boundary
|
|
31
|
+
// rule. A body with extra keys fails validation (a 400), so `c.body` never carries attacker-supplied
|
|
32
|
+
// properties (no mass-assignment). Use `t.looseObject` (or pass `{ additionalProperties: true }`) to
|
|
33
|
+
// opt into an open object; an explicit `options.additionalProperties` always wins over the default.
|
|
34
|
+
object: (props, options) => fromTypeBox(Type.Object(unwrap(props), { additionalProperties: false, ...options })),
|
|
35
|
+
/** Like `t.object` but ACCEPTS (and passes through) unknown fields — the explicit opt-out of the
|
|
36
|
+
* strict default. Prefer `t.object` unless you genuinely need an open object. */
|
|
37
|
+
looseObject: (props, options) => fromTypeBox(Type.Object(unwrap(props), { additionalProperties: true, ...options })),
|
|
38
|
+
array: (item, options) => fromTypeBox(Type.Array(item.jsonSchema, options)),
|
|
39
|
+
/** Marks a property optional inside `t.object`; standalone it is `T | undefined`. */
|
|
40
|
+
optional: (schema) => fromTypeBox(Type.Optional(schema.jsonSchema)),
|
|
41
|
+
// `const S` captures the argument as a tuple; the explicit return type then maps
|
|
42
|
+
// over that captured tuple (`S[K]["jsonSchema"]`) so the union's `Static` is
|
|
43
|
+
// `A | B`, not `unknown` — the value-level `.map` can't preserve per-element
|
|
44
|
+
// types, so the output type is derived from `S` and the result cast to match
|
|
45
|
+
// (order/length are preserved by `map`, so the tuple shape is sound).
|
|
46
|
+
union: (schemas) => fromTypeBox(Type.Union(schemas.map((schema) => schema.jsonSchema))),
|
|
47
|
+
record: (value, options) => fromTypeBox(Type.Record(Type.String(), value.jsonSchema, options)),
|
|
48
|
+
};
|
|
49
|
+
//# sourceMappingURL=t.js.map
|
package/dist/t.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"t.js","sourceRoot":"","sources":["../src/t.ts"],"names":[],"mappings":"AAAA,OAAO,EASL,IAAI,GACL,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EAAE,WAAW,EAAoB,MAAM,cAAc,CAAA;AAI5D,gFAAgF;AAChF,SAAS,MAAM,CAAkB,KAAQ;IACvC,MAAM,GAAG,GAA4B,EAAE,CAAA;IACvC,wEAAwE;IACxE,KAAK,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,GAAG,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC,UAAU,CAAA;IAC/E,OAAO,GAA6C,CAAA;AACtD,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,CAAC,GAAG;IACf,MAAM,EAAE,CAAC,OAAuB,EAAE,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IACtE,MAAM,EAAE,CAAC,OAAuB,EAAE,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IACtE,OAAO,EAAE,CAAC,OAAwB,EAAE,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;IACzE,OAAO,EAAE,GAAG,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC;IAC1C,IAAI,EAAE,GAAG,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;IACpC,OAAO,EAAE,CAAgC,KAAQ,EAAE,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;IAEtF,oGAAoG;IACpG,qGAAqG;IACrG,qGAAqG;IACrG,oGAAoG;IACpG,MAAM,EAAE,CAAkB,KAAQ,EAAE,OAAuB,EAAE,EAAE,CAC7D,WAAW,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,EAAE,oBAAoB,EAAE,KAAK,EAAE,GAAG,OAAO,EAAE,CAAC,CAAC;IACtF;qFACiF;IACjF,WAAW,EAAE,CAAkB,KAAQ,EAAE,OAAuB,EAAE,EAAE,CAClE,WAAW,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,EAAE,oBAAoB,EAAE,IAAI,EAAE,GAAG,OAAO,EAAE,CAAC,CAAC;IACrF,KAAK,EAAE,CAAoB,IAAoB,EAAE,OAAsB,EAAE,EAAE,CACzE,WAAW,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;IACnD,qFAAqF;IACrF,QAAQ,EAAE,CAAoB,MAAsB,EAAE,EAAE,CACtD,WAAW,CAAC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;IAC/C,iFAAiF;IACjF,6EAA6E;IAC7E,6EAA6E;IAC7E,6EAA6E;IAC7E,sEAAsE;IACtE,KAAK,EAAE,CAAyC,OAAU,EAAE,EAAE,CAC5D,WAAW,CACT,IAAI,CAAC,KAAK,CAAE,OAAkC,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,CACV;IAC5E,MAAM,EAAE,CAAoB,KAAqB,EAAE,OAAuB,EAAE,EAAE,CAC5E,WAAW,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,KAAK,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;CAC5D,CAAA"}
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nifrajs/schema",
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
4
|
+
"description": "Built-in schema builder `t` — TypeBox-backed (free JSON Schema + compiled validators), implements Standard Schema so nifra routes validate it natively.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"bun",
|
|
7
|
+
"typescript",
|
|
8
|
+
"nifra",
|
|
9
|
+
"web-framework",
|
|
10
|
+
"full-stack",
|
|
11
|
+
"edge",
|
|
12
|
+
"ssr",
|
|
13
|
+
"validation",
|
|
14
|
+
"typebox",
|
|
15
|
+
"json-schema",
|
|
16
|
+
"standard-schema"
|
|
17
|
+
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"module": "./dist/index.js",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"bun": "./src/index.ts",
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"default": "./dist/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./openapi": {
|
|
29
|
+
"bun": "./src/openapi.ts",
|
|
30
|
+
"types": "./dist/openapi.d.ts",
|
|
31
|
+
"default": "./dist/openapi.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"src"
|
|
37
|
+
],
|
|
38
|
+
"comment:sideEffects": "formats.ts registers string formats into TypeBox's global FormatRegistry on import — that file must not be tree-shaken away, so it (not the package) is marked side-effectful.",
|
|
39
|
+
"sideEffects": [
|
|
40
|
+
"./src/formats.ts",
|
|
41
|
+
"./dist/formats.js"
|
|
42
|
+
],
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsc -p tsconfig.build.json && bun run ../../scripts/fix-dts.ts dist"
|
|
45
|
+
},
|
|
46
|
+
"license": "MIT",
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@sinclair/typebox": "^0.34.49"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"@nifrajs/core": "workspace:*"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@nifrajs/core": "workspace:*",
|
|
58
|
+
"@nifrajs/test-utils": "workspace:*"
|
|
59
|
+
},
|
|
60
|
+
"repository": {
|
|
61
|
+
"type": "git",
|
|
62
|
+
"url": "git+https://github.com/nifrajs/nifra.git",
|
|
63
|
+
"directory": "packages/schema"
|
|
64
|
+
},
|
|
65
|
+
"bugs": {
|
|
66
|
+
"url": "https://github.com/nifrajs/nifra/issues"
|
|
67
|
+
},
|
|
68
|
+
"homepage": "https://github.com/nifrajs/nifra#readme"
|
|
69
|
+
}
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { StandardIssue, StandardResult, StandardSchemaV1, StandardTypes } from "@nifrajs/core"
|
|
2
|
+
import type { Static, TSchema } from "@sinclair/typebox"
|
|
3
|
+
import { type TypeCheck, TypeCompiler } from "@sinclair/typebox/compiler"
|
|
4
|
+
import { Value } from "@sinclair/typebox/value"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A `t` schema. It is a Standard Schema (so any nifra route validates it with no
|
|
8
|
+
* special-casing) whose raw TypeBox definition stays reachable as `jsonSchema` —
|
|
9
|
+
* and because a TypeBox schema *is* a JSON Schema, that field is exactly what lets
|
|
10
|
+
* `toOpenAPI` emit a real request/response schema for the route. BYO Standard
|
|
11
|
+
* Schemas (zod/valibot/arktype) validate too but expose no JSON Schema, so only
|
|
12
|
+
* `t`-based routes get full OpenAPI.
|
|
13
|
+
*/
|
|
14
|
+
export type NifraSchema<T extends TSchema = TSchema> = StandardSchemaV1<Static<T>, Static<T>> & {
|
|
15
|
+
readonly jsonSchema: T
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Map a TypeBox validation error to a Standard Schema issue. TypeBox reports JSON
|
|
20
|
+
* Pointers (`"/name"`, `"/items/0"`); Standard Schema wants a segment array (`""` is
|
|
21
|
+
* the document root → no path). The compiled checker and the eval-free `Value`
|
|
22
|
+
* checker emit the same error shape, so both validation paths share this.
|
|
23
|
+
*/
|
|
24
|
+
function toIssue(error: { readonly message: string; readonly path: string }): StandardIssue {
|
|
25
|
+
return {
|
|
26
|
+
message: error.message,
|
|
27
|
+
path: error.path === "" ? undefined : error.path.slice(1).split("/"),
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Wrap a TypeBox schema as a `NifraSchema`.
|
|
33
|
+
*
|
|
34
|
+
* **Validation is fast where it can be, and works everywhere.** The first `validate`
|
|
35
|
+
* builds a compiled `TypeCompiler` validator (codegen via `new Function`) and memoizes
|
|
36
|
+
* it — composing schemas (`t.object({ a: t.string() })`) never compiles the inner
|
|
37
|
+
* pieces; only a schema actually used to validate a request pays the one-time codegen
|
|
38
|
+
* cost. On Bun and Node this is the only path and it is untouched.
|
|
39
|
+
*
|
|
40
|
+
* Edge runtimes (Cloudflare Workers, Vercel Edge, Deno Deploy) **forbid dynamic code
|
|
41
|
+
* generation**, so that first compile throws there. We catch it once per schema and
|
|
42
|
+
* fall back to TypeBox's eval-free `Value` checker — same semantics (and same global
|
|
43
|
+
* `FormatRegistry`, so `t.string({ format })` behaves identically), no `new Function`.
|
|
44
|
+
* The branch is decided once and memoized, so the hot path is a single check either way.
|
|
45
|
+
*
|
|
46
|
+
* Exposed (not just used internally by `t`) so a TypeBox schema authored directly can
|
|
47
|
+
* be adapted without leaving the Standard Schema world.
|
|
48
|
+
*/
|
|
49
|
+
export function fromTypeBox<T extends TSchema>(schema: T): NifraSchema<T> {
|
|
50
|
+
let compiled: TypeCheck<T> | undefined
|
|
51
|
+
let evalFree = false
|
|
52
|
+
return {
|
|
53
|
+
"~standard": {
|
|
54
|
+
version: 1,
|
|
55
|
+
vendor: "nifra",
|
|
56
|
+
validate: (value: unknown): StandardResult<Static<T>> => {
|
|
57
|
+
if (compiled === undefined && !evalFree) {
|
|
58
|
+
try {
|
|
59
|
+
compiled = TypeCompiler.Compile(schema)
|
|
60
|
+
} catch {
|
|
61
|
+
// Dynamic codegen disallowed (edge): take the eval-free path for this schema.
|
|
62
|
+
evalFree = true
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (compiled !== undefined) {
|
|
66
|
+
if (compiled.Check(value)) return { value }
|
|
67
|
+
return { issues: [...compiled.Errors(value)].map(toIssue) }
|
|
68
|
+
}
|
|
69
|
+
if (Value.Check(schema, value)) return { value }
|
|
70
|
+
return { issues: [...Value.Errors(schema, value)].map(toIssue) }
|
|
71
|
+
},
|
|
72
|
+
// Phantom: `types` carries no runtime value; this cast supplies the
|
|
73
|
+
// compile-time `Static<T>` that nifra's `InferOutput` reads to type `c.body`.
|
|
74
|
+
types: undefined as unknown as StandardTypes<Static<T>, Static<T>>,
|
|
75
|
+
},
|
|
76
|
+
jsonSchema: schema,
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/formats.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { FormatRegistry } from "@sinclair/typebox"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Standard JSON Schema string formats, registered with TypeBox so `t.string({
|
|
5
|
+
* format })` both **validates** and **annotates** the JSON Schema (the latter feeds
|
|
6
|
+
* OpenAPI). This registration is the reason it works at all: TypeBox treats an
|
|
7
|
+
* *unregistered* format as a hard failure, so without it `t.string({ format:
|
|
8
|
+
* "email" })` would reject every value.
|
|
9
|
+
*
|
|
10
|
+
* Patterns are pragmatic (not full RFC grammars) but reject obvious garbage. Need a
|
|
11
|
+
* stricter rule or a format not listed here (e.g. `ipv6`, `hostname`)? Register it
|
|
12
|
+
* with {@link registerFormat}. Importing this module performs the registration as a
|
|
13
|
+
* one-time side effect; the `Has` guard means an app that registered its own
|
|
14
|
+
* validator first wins.
|
|
15
|
+
*/
|
|
16
|
+
const DEFAULT_FORMATS: Readonly<Record<string, RegExp>> = {
|
|
17
|
+
email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
|
18
|
+
uuid: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
|
19
|
+
"date-time": /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})$/,
|
|
20
|
+
date: /^\d{4}-\d{2}-\d{2}$/,
|
|
21
|
+
time: /^\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})?$/,
|
|
22
|
+
uri: /^[a-z][a-z0-9+.-]*:\S+$/i,
|
|
23
|
+
ipv4: /^(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)$/,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Register (or override) a string format usable as `t.string({ format: name })`. */
|
|
27
|
+
export function registerFormat(name: string, validate: (value: string) => boolean): void {
|
|
28
|
+
FormatRegistry.Set(name, validate)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (const [name, pattern] of Object.entries(DEFAULT_FORMATS)) {
|
|
32
|
+
if (!FormatRegistry.Has(name)) FormatRegistry.Set(name, (value) => pattern.test(value))
|
|
33
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/schema` — the optional, batteries-included schema builder for nifra.
|
|
3
|
+
*
|
|
4
|
+
* `t` is TypeBox-backed: validators are compiled (fast) and every schema is a JSON
|
|
5
|
+
* Schema (free OpenAPI). It implements Standard Schema, so `@nifrajs/core` validates
|
|
6
|
+
* it through the exact same path as any BYO library — `@nifrajs/core` never depends on
|
|
7
|
+
* a validator; this package is the opt-in default.
|
|
8
|
+
*/
|
|
9
|
+
export { fromTypeBox, type NifraSchema } from "./adapter.ts"
|
|
10
|
+
// Re-exporting evaluates ./formats.ts, registering the standard string formats so
|
|
11
|
+
// `t.string({ format })` validates instead of rejecting everything.
|
|
12
|
+
export { registerFormat } from "./formats.ts"
|
|
13
|
+
export { type OpenAPIDocument, type OpenAPIInfo, toOpenAPI } from "./openapi.ts"
|
|
14
|
+
export { t } from "./t.ts"
|
package/src/openapi.ts
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import type { ContractShape, Server, StandardSchemaV1 } from "@nifrajs/core"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* OpenAPI 3.1 generation. We model a practical slice of the spec — enough to feed Swagger UI / codegen
|
|
5
|
+
* and to validate structurally: paths, parameters, request bodies, responses (incl. non-200 and
|
|
6
|
+
* non-JSON), tags, security, servers, and `$ref` reuse via `components.schemas`.
|
|
7
|
+
*
|
|
8
|
+
* Schemas carry full detail only for `t`/TypeBox inputs (they expose a JSON Schema); a BYO Standard
|
|
9
|
+
* Schema validates at runtime but exposes no JSON Schema, so its route is emitted without body/response
|
|
10
|
+
* detail. A **contract** is richest — its operations carry `response`, `tags`, `security`, additional
|
|
11
|
+
* `responses`, etc.; an **app** emits request shapes + a generic `200` (an inline handler has no
|
|
12
|
+
* response *schema* to serialize), enrichable via `options.operations`.
|
|
13
|
+
*/
|
|
14
|
+
export interface OpenAPIInfo {
|
|
15
|
+
readonly title: string
|
|
16
|
+
readonly version: string
|
|
17
|
+
readonly description?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface OpenAPIServer {
|
|
21
|
+
readonly url: string
|
|
22
|
+
readonly description?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface OpenAPITag {
|
|
26
|
+
readonly name: string
|
|
27
|
+
readonly description?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A document-wide / per-operation security requirement: scheme name → required scopes. */
|
|
31
|
+
export type SecurityRequirement = Readonly<Record<string, readonly string[]>>
|
|
32
|
+
|
|
33
|
+
export interface ToOpenAPIOptions {
|
|
34
|
+
readonly title?: string
|
|
35
|
+
readonly version?: string
|
|
36
|
+
readonly description?: string
|
|
37
|
+
/** Server URLs the API is served from (OpenAPI `servers`). */
|
|
38
|
+
readonly servers?: readonly OpenAPIServer[]
|
|
39
|
+
/** Tag definitions (OpenAPI top-level `tags`) — names referenced by an operation's `tags`. */
|
|
40
|
+
readonly tags?: readonly OpenAPITag[]
|
|
41
|
+
/** Reusable security schemes → `components.securitySchemes` (e.g. `{ bearer: { type: "http", scheme: "bearer" } }`). */
|
|
42
|
+
readonly securitySchemes?: Readonly<Record<string, Record<string, unknown>>>
|
|
43
|
+
/** Document-wide security requirement; a per-operation `security` (incl. `[]` for public) overrides it. */
|
|
44
|
+
readonly security?: readonly SecurityRequirement[]
|
|
45
|
+
/**
|
|
46
|
+
* Per-operation overrides, shallow-merged over the generated operation. Keyed by `operationId`
|
|
47
|
+
* (contract op name) or `"METHOD /path"` (e.g. `"GET /users/:id"`). The escape hatch for detail that
|
|
48
|
+
* can't be introspected — richer response bodies, examples, app-route tags/security.
|
|
49
|
+
*/
|
|
50
|
+
readonly operations?: Readonly<Record<string, Record<string, unknown>>>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type JsonSchema = Record<string, unknown>
|
|
54
|
+
|
|
55
|
+
interface OpenAPIParameter {
|
|
56
|
+
readonly name: string
|
|
57
|
+
readonly in: "path" | "query"
|
|
58
|
+
readonly required: boolean
|
|
59
|
+
readonly schema: JsonSchema
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface OpenAPIMediaType {
|
|
63
|
+
readonly schema: JsonSchema
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface OpenAPIRequestBody {
|
|
67
|
+
readonly required: boolean
|
|
68
|
+
readonly content: Record<string, OpenAPIMediaType>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface OpenAPIResponse {
|
|
72
|
+
description: string
|
|
73
|
+
content?: Record<string, OpenAPIMediaType>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface OpenAPIOperation {
|
|
77
|
+
operationId?: string
|
|
78
|
+
summary?: string
|
|
79
|
+
description?: string
|
|
80
|
+
tags?: readonly string[]
|
|
81
|
+
deprecated?: boolean
|
|
82
|
+
security?: readonly SecurityRequirement[]
|
|
83
|
+
parameters?: OpenAPIParameter[]
|
|
84
|
+
requestBody?: OpenAPIRequestBody
|
|
85
|
+
responses: Record<string, OpenAPIResponse>
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface OpenAPIComponents {
|
|
89
|
+
schemas?: Record<string, JsonSchema>
|
|
90
|
+
securitySchemes?: Readonly<Record<string, Record<string, unknown>>>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface OpenAPIDocument {
|
|
94
|
+
readonly openapi: "3.1.0"
|
|
95
|
+
readonly info: OpenAPIInfo
|
|
96
|
+
readonly paths: Record<string, Record<string, OpenAPIOperation>>
|
|
97
|
+
readonly servers?: readonly OpenAPIServer[]
|
|
98
|
+
readonly tags?: readonly OpenAPITag[]
|
|
99
|
+
readonly security?: readonly SecurityRequirement[]
|
|
100
|
+
readonly components?: OpenAPIComponents
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A Standard Schema that also carries a raw JSON Schema (i.e. a `t`/TypeBox schema). */
|
|
104
|
+
type WithJsonSchema = StandardSchemaV1 & { readonly jsonSchema: unknown }
|
|
105
|
+
|
|
106
|
+
function hasJsonSchema(schema: StandardSchemaV1): schema is WithJsonSchema {
|
|
107
|
+
return (
|
|
108
|
+
"jsonSchema" in schema &&
|
|
109
|
+
(schema as { jsonSchema?: unknown }).jsonSchema !== undefined &&
|
|
110
|
+
(schema as { jsonSchema?: unknown }).jsonSchema !== null
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Extract clean JSON Schema from a schema, or `undefined` for a BYO Standard Schema with no JSON
|
|
116
|
+
* Schema. The `JSON` round-trip strips TypeBox's Symbol-keyed metadata; a string `$id` survives it and
|
|
117
|
+
* drives `$ref` reuse.
|
|
118
|
+
*/
|
|
119
|
+
function toJsonSchema(schema: StandardSchemaV1): JsonSchema | undefined {
|
|
120
|
+
if (!hasJsonSchema(schema)) return undefined
|
|
121
|
+
return JSON.parse(JSON.stringify(schema.jsonSchema)) as JsonSchema
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Collects schemas that carry a `$id` into `components.schemas`, returning a `$ref` in their place —
|
|
126
|
+
* so a schema used by N operations is emitted once. Schemas without a `$id` stay inline (the existing
|
|
127
|
+
* behavior). The first sighting of an id wins; later ones just `$ref` it.
|
|
128
|
+
*/
|
|
129
|
+
class SchemaStore {
|
|
130
|
+
readonly schemas: Record<string, JsonSchema> = {}
|
|
131
|
+
|
|
132
|
+
/** Hoist a schema with a `$id` into components + return a `$ref`; otherwise return it inline. */
|
|
133
|
+
collect(schema: JsonSchema | undefined): JsonSchema | undefined {
|
|
134
|
+
if (schema === undefined) return undefined
|
|
135
|
+
const id = typeof schema.$id === "string" ? schema.$id : undefined
|
|
136
|
+
if (id === undefined) return schema
|
|
137
|
+
if (this.schemas[id] === undefined) {
|
|
138
|
+
const hoisted = { ...schema }
|
|
139
|
+
delete hoisted.$id // the component key is the name; drop the redundant base-URI hint
|
|
140
|
+
this.schemas[id] = hoisted
|
|
141
|
+
}
|
|
142
|
+
return { $ref: `#/components/schemas/${id}` }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Name of a path segment's parameter, or `undefined` for a static segment. */
|
|
147
|
+
function segmentParam(segment: string): string | undefined {
|
|
148
|
+
if (segment.startsWith(":")) return segment.slice(1)
|
|
149
|
+
if (segment.startsWith("*")) return segment.length > 1 ? segment.slice(1) : "wildcard"
|
|
150
|
+
return undefined
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** `/users/:id/*rest` → `/users/{id}/{rest}` (OpenAPI path templating). */
|
|
154
|
+
function toTemplatedPath(path: string): string {
|
|
155
|
+
return path
|
|
156
|
+
.split("/")
|
|
157
|
+
.map((segment) => {
|
|
158
|
+
const param = segmentParam(segment)
|
|
159
|
+
return param === undefined ? segment : `{${param}}`
|
|
160
|
+
})
|
|
161
|
+
.join("/")
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function pathParameters(path: string): OpenAPIParameter[] {
|
|
165
|
+
const params: OpenAPIParameter[] = []
|
|
166
|
+
for (const segment of path.split("/")) {
|
|
167
|
+
const name = segmentParam(segment)
|
|
168
|
+
// Path params are always strings (decoded from the URL).
|
|
169
|
+
if (name !== undefined)
|
|
170
|
+
params.push({ name, in: "path", required: true, schema: { type: "string" } })
|
|
171
|
+
}
|
|
172
|
+
return params
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function queryParameters(schema: StandardSchemaV1 | undefined): OpenAPIParameter[] {
|
|
176
|
+
if (schema === undefined) return []
|
|
177
|
+
const json = toJsonSchema(schema)
|
|
178
|
+
// Only an object schema decomposes into individual query parameters.
|
|
179
|
+
if (json === undefined || json.type !== "object" || typeof json.properties !== "object") return []
|
|
180
|
+
const properties = json.properties as Record<string, JsonSchema>
|
|
181
|
+
const required = Array.isArray(json.required) ? (json.required as string[]) : []
|
|
182
|
+
return Object.entries(properties).map(([name, propSchema]) => ({
|
|
183
|
+
name,
|
|
184
|
+
in: "query",
|
|
185
|
+
required: required.includes(name),
|
|
186
|
+
schema: propSchema,
|
|
187
|
+
}))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
interface OperationInput {
|
|
191
|
+
readonly path: string
|
|
192
|
+
readonly body: StandardSchemaV1 | undefined
|
|
193
|
+
readonly query: StandardSchemaV1 | undefined
|
|
194
|
+
readonly response: StandardSchemaV1 | undefined
|
|
195
|
+
readonly operationId: string | undefined
|
|
196
|
+
// `| undefined` (not just `?`) so a contract op's optional fields — `string | undefined` etc. — are
|
|
197
|
+
// assignable under `exactOptionalPropertyTypes` when spread into this literal.
|
|
198
|
+
readonly summary?: string | undefined
|
|
199
|
+
readonly description?: string | undefined
|
|
200
|
+
readonly tags?: readonly string[] | undefined
|
|
201
|
+
readonly deprecated?: boolean | undefined
|
|
202
|
+
readonly security?: readonly SecurityRequirement[] | undefined
|
|
203
|
+
readonly requestContentType?: string | undefined
|
|
204
|
+
readonly responseContentType?: string | undefined
|
|
205
|
+
readonly responses?:
|
|
206
|
+
| Readonly<
|
|
207
|
+
Record<string, { description?: string; schema?: StandardSchemaV1; contentType?: string }>
|
|
208
|
+
>
|
|
209
|
+
| undefined
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function buildResponses(
|
|
213
|
+
input: OperationInput,
|
|
214
|
+
store: SchemaStore,
|
|
215
|
+
): Record<string, OpenAPIResponse> {
|
|
216
|
+
const responses: Record<string, OpenAPIResponse> = {}
|
|
217
|
+
if (input.response !== undefined) {
|
|
218
|
+
const schema = store.collect(toJsonSchema(input.response))
|
|
219
|
+
responses["200"] =
|
|
220
|
+
schema !== undefined
|
|
221
|
+
? {
|
|
222
|
+
description: "OK",
|
|
223
|
+
content: { [input.responseContentType ?? "application/json"]: { schema } },
|
|
224
|
+
}
|
|
225
|
+
: { description: "OK" }
|
|
226
|
+
} else {
|
|
227
|
+
responses["200"] = { description: "OK" }
|
|
228
|
+
}
|
|
229
|
+
// Additional (or overriding) responses declared on the contract op.
|
|
230
|
+
if (input.responses !== undefined) {
|
|
231
|
+
for (const [status, def] of Object.entries(input.responses)) {
|
|
232
|
+
const response: OpenAPIResponse = { description: def.description ?? "" }
|
|
233
|
+
const schema = def.schema !== undefined ? store.collect(toJsonSchema(def.schema)) : undefined
|
|
234
|
+
if (schema !== undefined)
|
|
235
|
+
response.content = { [def.contentType ?? "application/json"]: { schema } }
|
|
236
|
+
responses[status] = response
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return responses
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function buildOperation(input: OperationInput, store: SchemaStore): OpenAPIOperation {
|
|
243
|
+
const operation: OpenAPIOperation = { responses: buildResponses(input, store) }
|
|
244
|
+
if (input.operationId !== undefined) operation.operationId = input.operationId
|
|
245
|
+
if (input.summary !== undefined) operation.summary = input.summary
|
|
246
|
+
if (input.description !== undefined) operation.description = input.description
|
|
247
|
+
if (input.tags !== undefined && input.tags.length > 0) operation.tags = input.tags
|
|
248
|
+
if (input.deprecated === true) operation.deprecated = true
|
|
249
|
+
if (input.security !== undefined) operation.security = input.security // `[]` ⇒ explicitly public
|
|
250
|
+
|
|
251
|
+
const parameters = [...pathParameters(input.path), ...queryParameters(input.query)]
|
|
252
|
+
if (parameters.length > 0) operation.parameters = parameters
|
|
253
|
+
|
|
254
|
+
if (input.body !== undefined) {
|
|
255
|
+
const schema = store.collect(toJsonSchema(input.body))
|
|
256
|
+
if (schema !== undefined) {
|
|
257
|
+
operation.requestBody = {
|
|
258
|
+
required: true,
|
|
259
|
+
content: { [input.requestContentType ?? "application/json"]: { schema } },
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return operation
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function addOperation(
|
|
267
|
+
paths: Record<string, Record<string, OpenAPIOperation>>,
|
|
268
|
+
method: string,
|
|
269
|
+
input: OperationInput,
|
|
270
|
+
store: SchemaStore,
|
|
271
|
+
operations: ToOpenAPIOptions["operations"],
|
|
272
|
+
): void {
|
|
273
|
+
const templated = toTemplatedPath(input.path)
|
|
274
|
+
const pathItem = paths[templated] ?? {}
|
|
275
|
+
paths[templated] = pathItem
|
|
276
|
+
let operation = buildOperation(input, store)
|
|
277
|
+
// Shallow-merge an override keyed by operationId or "METHOD /path".
|
|
278
|
+
const override =
|
|
279
|
+
operations?.[input.operationId ?? ""] ?? operations?.[`${method.toUpperCase()} ${input.path}`]
|
|
280
|
+
if (override !== undefined) operation = { ...operation, ...override } as OpenAPIOperation
|
|
281
|
+
pathItem[method.toLowerCase()] = operation
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function isApp(input: ContractShape | Server): input is Server {
|
|
285
|
+
// Duck-typed so @nifrajs/schema keeps @nifrajs/core a type-only dependency. A contract
|
|
286
|
+
// is a plain record of operations; only a Server exposes a `routes()` method.
|
|
287
|
+
return typeof (input as { routes?: unknown }).routes === "function"
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Generate an OpenAPI 3.1 document from a contract or a running app. See the module doc for the detail model. */
|
|
291
|
+
export function toOpenAPI(
|
|
292
|
+
input: ContractShape | Server,
|
|
293
|
+
options: ToOpenAPIOptions = {},
|
|
294
|
+
): OpenAPIDocument {
|
|
295
|
+
const paths: Record<string, Record<string, OpenAPIOperation>> = {}
|
|
296
|
+
const store = new SchemaStore()
|
|
297
|
+
|
|
298
|
+
if (isApp(input)) {
|
|
299
|
+
for (const route of input.routes()) {
|
|
300
|
+
addOperation(
|
|
301
|
+
paths,
|
|
302
|
+
route.method,
|
|
303
|
+
{
|
|
304
|
+
path: route.path,
|
|
305
|
+
body: route.schema?.body,
|
|
306
|
+
query: route.schema?.query,
|
|
307
|
+
// A route may now declare a `response` contract — emit it as the 200 body schema.
|
|
308
|
+
response: route.schema?.response,
|
|
309
|
+
operationId: undefined,
|
|
310
|
+
},
|
|
311
|
+
store,
|
|
312
|
+
options.operations,
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
} else {
|
|
316
|
+
for (const [name, op] of Object.entries(input)) {
|
|
317
|
+
addOperation(
|
|
318
|
+
paths,
|
|
319
|
+
op.method,
|
|
320
|
+
{
|
|
321
|
+
path: op.path,
|
|
322
|
+
body: op.body,
|
|
323
|
+
query: op.query,
|
|
324
|
+
response: op.response,
|
|
325
|
+
operationId: name,
|
|
326
|
+
summary: op.summary,
|
|
327
|
+
description: op.description,
|
|
328
|
+
tags: op.tags,
|
|
329
|
+
deprecated: op.deprecated,
|
|
330
|
+
security: op.security,
|
|
331
|
+
requestContentType: op.requestContentType,
|
|
332
|
+
responseContentType: op.responseContentType,
|
|
333
|
+
responses: op.responses,
|
|
334
|
+
},
|
|
335
|
+
store,
|
|
336
|
+
options.operations,
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const components: OpenAPIComponents = {}
|
|
342
|
+
if (Object.keys(store.schemas).length > 0) components.schemas = store.schemas
|
|
343
|
+
if (options.securitySchemes !== undefined) components.securitySchemes = options.securitySchemes
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
openapi: "3.1.0",
|
|
347
|
+
info: {
|
|
348
|
+
title: options.title ?? "API",
|
|
349
|
+
version: options.version ?? "1.0.0",
|
|
350
|
+
...(options.description !== undefined ? { description: options.description } : {}),
|
|
351
|
+
},
|
|
352
|
+
paths,
|
|
353
|
+
...(options.servers !== undefined ? { servers: options.servers } : {}),
|
|
354
|
+
...(options.tags !== undefined ? { tags: options.tags } : {}),
|
|
355
|
+
...(options.security !== undefined ? { security: options.security } : {}),
|
|
356
|
+
...(Object.keys(components).length > 0 ? { components } : {}),
|
|
357
|
+
}
|
|
358
|
+
}
|