@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/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
+ }