@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.
@@ -0,0 +1,410 @@
1
+ /**
2
+ * OpenAPI 3.x inventory import - the structural half of an export/import roundtrip.
3
+ *
4
+ * `toOpenAPI` renders a Nifra app (or contract) as an OpenAPI 3.1 document. This module reads such
5
+ * a document back into a canonical route inventory: method, Nifra-templated path (`{id}` back to
6
+ * `:id`), required query names, body presence, and response statuses. `diffOpenApiInventory` then
7
+ * compares two inventories, so `export -> import -> diff` proves the cycle survives in CI.
8
+ *
9
+ * Deliberate boundaries, all fail-closed or explicitly documented:
10
+ *
11
+ * - Offline and ref-opaque. `$ref`s are never resolved and nothing is fetched. A `$ref` parameter
12
+ * cannot yield a name without resolution, so it throws with the location; `$ref` bodies and
13
+ * response schemas only assert presence, which needs no resolution.
14
+ * - No schema reconstruction. JSON Schema is not turned back into `t` builders here - that is a
15
+ * separate codegen step needing consumer evidence. The inventory proves the route table survived;
16
+ * field-level types ride along in the source document, not in this comparison.
17
+ * - No auth interpretation. `security`, `securitySchemes`, and `servers` are never read. Importing
18
+ * an API must not silently adopt its auth model.
19
+ * - Templated paths degrade conservatively. `/files/*path` exports as `/files/{path}`, indistinguishable
20
+ * from a single-segment `:path`. Import yields `:path` and records a note for every OpenAPI template;
21
+ * a greedy remainder needs a manual `*path` rewrite.
22
+ * - Nifra parameter names follow `/^[A-Za-z_][A-Za-z0-9_]*$/`; `__proto__`, `constructor`, and
23
+ * `prototype` are rejected. Mixed templates are converted only when their boundaries are representable.
24
+ * - Bounded. Path/operation/parameter counts are capped (`ImportOpenAPIOptions`); a document over
25
+ * the caps throws rather than allocating.
26
+ */
27
+
28
+ export class OpenAPIImportError extends Error {
29
+ constructor(message: string) {
30
+ super(`openapi import: ${message}`)
31
+ this.name = "OpenAPIImportError"
32
+ }
33
+ }
34
+
35
+ export interface ImportOpenAPIOptions {
36
+ /** Maximum entries under `paths`. Default 5000. */
37
+ readonly maxPaths?: number
38
+ /** Maximum operations on one path item. Default 8. */
39
+ readonly maxOperationsPerPath?: number
40
+ /** Maximum merged parameters per operation. Default 100. */
41
+ readonly maxParameters?: number
42
+ }
43
+
44
+ export interface ImportedRouteQuery {
45
+ readonly name: string
46
+ readonly required: boolean
47
+ }
48
+
49
+ export interface ImportedRoute {
50
+ /** Lowercase HTTP method as it appears in the document. */
51
+ readonly method: string
52
+ /** Nifra-templated path (`/users/:id`). */
53
+ readonly path: string
54
+ readonly operationId?: string
55
+ /** Template parameter names in path order. */
56
+ readonly pathParams: readonly string[]
57
+ readonly query: readonly ImportedRouteQuery[]
58
+ readonly hasBody: boolean
59
+ readonly requestBodyRequired: boolean
60
+ /** Response status keys (`"200"`, `"404"`, `"default"`). */
61
+ readonly responseStatuses: readonly string[]
62
+ /** Per-route degradations (wildcard rewrites). */
63
+ readonly notes: readonly string[]
64
+ }
65
+
66
+ export interface ImportedApiInventory {
67
+ readonly title: string
68
+ readonly version: string
69
+ readonly routes: readonly ImportedRoute[]
70
+ readonly warnings: readonly string[]
71
+ }
72
+
73
+ export interface OpenApiInventoryDrift {
74
+ /** `METHOD /path`, or `inventory` for route-set level drift. */
75
+ readonly route: string
76
+ readonly message: string
77
+ }
78
+
79
+ const SUPPORTED_METHODS: ReadonlySet<string> = new Set([
80
+ "get",
81
+ "put",
82
+ "post",
83
+ "delete",
84
+ "patch",
85
+ "head",
86
+ "options",
87
+ "trace",
88
+ ])
89
+ const PATH_ITEM_METADATA = new Set(["$ref", "summary", "description", "servers", "parameters"])
90
+ const RESPONSE_KEY = /^(?:[1-5][0-9]{2}|default)$/
91
+ const TEMPLATE_EXPRESSION = /\{([^{}]*)\}/g
92
+ const NIFRA_PARAM = /^[A-Za-z_][A-Za-z0-9_]*$/
93
+ const RESERVED_PARAM_NAMES = new Set(["__proto__", "constructor", "prototype"])
94
+
95
+ function isRecord(value: unknown): value is Record<string, unknown> {
96
+ return typeof value === "object" && value !== null && !Array.isArray(value)
97
+ }
98
+
99
+ function normalizedLimit(value: number | undefined, fallback: number, label: string): number {
100
+ const max = value ?? fallback
101
+ if (!Number.isSafeInteger(max) || max < 0)
102
+ throw new OpenAPIImportError(`${label} limit must be a finite safe integer >= 0`)
103
+ return max
104
+ }
105
+
106
+ function assertLimit(count: number, max: number, label: string): void {
107
+ if (count > max) throw new OpenAPIImportError(`${label} count ${count} exceeds limit ${max}`)
108
+ }
109
+
110
+ /** Collect own keys while stopping as soon as a bounded collection is over its cap. */
111
+ function ownKeysWithinLimit(
112
+ record: Record<string, unknown>,
113
+ max: number,
114
+ label: string,
115
+ include: (key: string) => boolean = () => true,
116
+ ): string[] {
117
+ const keys: string[] = []
118
+ for (const key in record) {
119
+ if (!Object.hasOwn(record, key) || !include(key)) continue
120
+ if (keys.length >= max)
121
+ throw new OpenAPIImportError(`${label} count ${keys.length + 1} exceeds limit ${max}`)
122
+ keys.push(key)
123
+ }
124
+ return keys
125
+ }
126
+
127
+ function toNifraPath(openapiPath: string): {
128
+ readonly path: string
129
+ readonly templated: boolean
130
+ readonly pathParams: readonly string[]
131
+ } {
132
+ const segments = openapiPath.split("/")
133
+ let templated = false
134
+ const pathParams: string[] = []
135
+ const out = segments.map((segment) => {
136
+ if (!segment.includes("{") && !segment.includes("}")) return segment
137
+ const matches = [...segment.matchAll(TEMPLATE_EXPRESSION)]
138
+ if (matches.length === 0)
139
+ throw new OpenAPIImportError(`unparseable path template ${openapiPath}`)
140
+ let cursor = 0
141
+ let converted = ""
142
+ for (const match of matches) {
143
+ if (match.index === undefined)
144
+ throw new OpenAPIImportError(`unparseable path template ${openapiPath}`)
145
+ const start = match.index
146
+ const end = start + match[0].length
147
+ const name = match[1] ?? ""
148
+ const previous = segment[start - 1]
149
+ const next = segment[end]
150
+ // Nifra's mixed grammar needs a non-identifier delimiter after a parameter. A terminal
151
+ // `foo{bar}` or adjacent `{a}{b}` cannot be represented without changing the capture.
152
+ if (
153
+ next === "{" ||
154
+ (next !== undefined && /[A-Za-z0-9_]/.test(next)) ||
155
+ (next === undefined && previous !== undefined && /[A-Za-z0-9_]/.test(previous))
156
+ )
157
+ throw new OpenAPIImportError(`path template ${openapiPath} is not representable in Nifra`)
158
+ if (!NIFRA_PARAM.test(name) || RESERVED_PARAM_NAMES.has(name))
159
+ throw new OpenAPIImportError(`invalid path parameter ${name}`)
160
+ if (pathParams.includes(name))
161
+ throw new OpenAPIImportError(`duplicate path parameter ${name} on ${openapiPath}`)
162
+ pathParams.push(name)
163
+ converted += `${segment.slice(cursor, start)}:${name}`
164
+ cursor = end
165
+ templated = true
166
+ }
167
+ const tail = segment.slice(cursor)
168
+ if (tail.includes("{") || tail.includes("}"))
169
+ throw new OpenAPIImportError(`unparseable path template ${openapiPath}`)
170
+ return converted + tail
171
+ })
172
+ return { path: out.join("/"), templated, pathParams: Object.freeze(pathParams) }
173
+ }
174
+
175
+ interface MergedParameter {
176
+ readonly name: string
177
+ readonly location: string
178
+ readonly required: boolean
179
+ }
180
+
181
+ function mergedParameters(
182
+ pathItem: Record<string, unknown>,
183
+ operation: Record<string, unknown>,
184
+ max: number,
185
+ ): MergedParameter[] {
186
+ const merged = new Map<string, MergedParameter>()
187
+ for (const source of [pathItem.parameters, operation.parameters]) {
188
+ if (source === undefined) continue
189
+ if (!Array.isArray(source)) throw new OpenAPIImportError("parameters must be an array")
190
+ assertLimit(source.length, max, "parameters")
191
+ for (const entry of source) {
192
+ if (!isRecord(entry)) throw new OpenAPIImportError("parameter entries must be objects")
193
+ if ("$ref" in entry)
194
+ throw new OpenAPIImportError(
195
+ "parameter $refs must be inlined before import - refs are never resolved",
196
+ )
197
+ const {
198
+ name,
199
+ in: location,
200
+ required,
201
+ } = entry as {
202
+ name?: unknown
203
+ in?: unknown
204
+ required?: unknown
205
+ }
206
+ if (typeof name !== "string" || name === "")
207
+ throw new OpenAPIImportError("parameters need a non-empty name")
208
+ if (typeof location !== "string" || location === "")
209
+ throw new OpenAPIImportError(`parameter ${name} needs an 'in' location`)
210
+ merged.set(`${location} ${name}`, {
211
+ name,
212
+ location,
213
+ required: required === true,
214
+ })
215
+ }
216
+ }
217
+ assertLimit(merged.size, max, "merged parameters")
218
+ return [...merged.values()]
219
+ }
220
+
221
+ /** Parse an OpenAPI 3.0/3.1 document into a canonical route inventory. Throws on invalid input. */
222
+ export function importOpenAPI(
223
+ document: unknown,
224
+ options: ImportOpenAPIOptions = {},
225
+ ): ImportedApiInventory {
226
+ const maxPaths = normalizedLimit(options.maxPaths, 5000, "maxPaths")
227
+ const maxOperations = normalizedLimit(options.maxOperationsPerPath, 8, "maxOperationsPerPath")
228
+ const maxParameters = normalizedLimit(options.maxParameters, 100, "maxParameters")
229
+ if (!isRecord(document)) throw new OpenAPIImportError("document must be an object")
230
+ const { openapi, info, paths } = document
231
+ if (typeof openapi !== "string" || !/^3\.(0|1)\.\d+$/.test(openapi))
232
+ throw new OpenAPIImportError(
233
+ `unsupported OpenAPI version ${JSON.stringify(openapi)} - 3.0.x and 3.1.x only`,
234
+ )
235
+ if (!isRecord(info)) throw new OpenAPIImportError("document.info must be an object")
236
+ const title = typeof info.title === "string" ? info.title : ""
237
+ const version = typeof info.version === "string" ? info.version : ""
238
+ if (paths === undefined)
239
+ return { title, version, routes: Object.freeze([]), warnings: Object.freeze([]) }
240
+ if (!isRecord(paths)) throw new OpenAPIImportError("document.paths must be an object")
241
+ const pathKeys = ownKeysWithinLimit(paths, maxPaths, "paths")
242
+ const warnings: string[] = []
243
+ const routes: ImportedRoute[] = []
244
+ for (const openapiPath of pathKeys) {
245
+ if (!openapiPath.startsWith("/"))
246
+ throw new OpenAPIImportError(`path ${openapiPath} must start with /`)
247
+ const pathItem = paths[openapiPath]
248
+ if (!isRecord(pathItem))
249
+ throw new OpenAPIImportError(`path item ${openapiPath} must be an object`)
250
+ if (Object.hasOwn(pathItem, "$ref"))
251
+ throw new OpenAPIImportError(
252
+ `path item $refs must be inlined before import - refs are never resolved`,
253
+ )
254
+ const operationKeys = ownKeysWithinLimit(
255
+ pathItem,
256
+ maxOperations,
257
+ `operations on ${openapiPath}`,
258
+ (key) => key !== "parameters" && !PATH_ITEM_METADATA.has(key) && !key.startsWith("x-"),
259
+ )
260
+ const { path, templated, pathParams } = toNifraPath(openapiPath)
261
+ const seenMethods = new Set<string>()
262
+ for (const rawMethod of operationKeys) {
263
+ const method = rawMethod.toLowerCase()
264
+ if (!SUPPORTED_METHODS.has(method))
265
+ throw new OpenAPIImportError(`unsupported method ${rawMethod} on ${openapiPath}`)
266
+ if (seenMethods.has(method))
267
+ throw new OpenAPIImportError(`duplicate method ${rawMethod} on ${openapiPath}`)
268
+ seenMethods.add(method)
269
+ const rawOperation = pathItem[rawMethod]
270
+ if (!isRecord(rawOperation))
271
+ throw new OpenAPIImportError(`operation ${rawMethod} ${openapiPath} must be an object`)
272
+ const params = mergedParameters(pathItem, rawOperation, maxParameters)
273
+ const query: ImportedRouteQuery[] = []
274
+ for (const param of params) {
275
+ if (param.location === "query") query.push({ name: param.name, required: param.required })
276
+ else if (param.location !== "path" && param.location !== "header")
277
+ warnings.push(
278
+ `${method.toUpperCase()} ${path}: '${param.location}' parameter '${param.name}' skipped`,
279
+ )
280
+ else if (param.location === "header")
281
+ warnings.push(`${method.toUpperCase()} ${path}: header parameters are not inventoried`)
282
+ }
283
+ const declaredPathParams = new Set(
284
+ params.filter((param) => param.location === "path").map((param) => param.name),
285
+ )
286
+ for (const name of pathParams) {
287
+ if (!declaredPathParams.has(name))
288
+ throw new OpenAPIImportError(
289
+ `path parameter ${name} is not declared on ${method.toUpperCase()} ${path}`,
290
+ )
291
+ }
292
+ for (const name of declaredPathParams) {
293
+ if (!pathParams.includes(name))
294
+ throw new OpenAPIImportError(`declared path parameter ${name} is not in ${path}`)
295
+ }
296
+ const rawBody = rawOperation.requestBody
297
+ const hasBody = rawBody !== undefined
298
+ if (hasBody && !isRecord(rawBody))
299
+ throw new OpenAPIImportError(
300
+ `requestBody on ${method.toUpperCase()} ${path} must be an object`,
301
+ )
302
+ const rawResponses = rawOperation.responses
303
+ if (rawResponses === undefined)
304
+ throw new OpenAPIImportError(`responses are required on ${method.toUpperCase()} ${path}`)
305
+ if (!isRecord(rawResponses))
306
+ throw new OpenAPIImportError(
307
+ `responses on ${method.toUpperCase()} ${path} must be an object`,
308
+ )
309
+ const responseStatuses: string[] = []
310
+ for (const status of Object.keys(rawResponses)) {
311
+ if (!RESPONSE_KEY.test(status))
312
+ throw new OpenAPIImportError(
313
+ `invalid response status ${status} on ${method.toUpperCase()} ${path}`,
314
+ )
315
+ if (!isRecord(rawResponses[status]))
316
+ throw new OpenAPIImportError(
317
+ `response ${status} on ${method.toUpperCase()} ${path} must be an object`,
318
+ )
319
+ responseStatuses.push(status)
320
+ }
321
+ const notes = templated
322
+ ? [
323
+ "OpenAPI path templates import as single-segment :params - greedy remainders (*rest) cannot be distinguished and need manual rewrites",
324
+ ]
325
+ : []
326
+ const operationId = rawOperation.operationId
327
+ routes.push(
328
+ Object.freeze({
329
+ method,
330
+ path,
331
+ ...(typeof operationId === "string" && operationId !== "" ? { operationId } : {}),
332
+ pathParams,
333
+ query: Object.freeze(query),
334
+ hasBody,
335
+ requestBodyRequired: hasBody && (rawBody as Record<string, unknown>).required === true,
336
+ responseStatuses: Object.freeze(responseStatuses),
337
+ notes: Object.freeze(notes),
338
+ }),
339
+ )
340
+ }
341
+ }
342
+ if (routes.some((route) => route.pathParams.length > 0))
343
+ warnings.push(
344
+ "OpenAPI path templates import as single-segment :params - greedy remainders (*rest) cannot be distinguished and need manual rewrites; see route notes",
345
+ )
346
+ return {
347
+ title,
348
+ version,
349
+ routes: Object.freeze(routes),
350
+ warnings: Object.freeze(warnings),
351
+ }
352
+ }
353
+
354
+ function routeKey(method: string, path: string): string {
355
+ return `${method.toUpperCase()} ${path}`
356
+ }
357
+
358
+ /** Compare two inventories. Empty means the export/import cycle preserved the route table. */
359
+ export function diffOpenApiInventory(
360
+ expected: ImportedApiInventory,
361
+ actual: ImportedApiInventory,
362
+ ): readonly OpenApiInventoryDrift[] {
363
+ const drift: OpenApiInventoryDrift[] = []
364
+ const actualByKey = new Map(
365
+ actual.routes.map((route) => [routeKey(route.method, route.path), route]),
366
+ )
367
+ const expectedKeys = new Set<string>()
368
+ for (const want of expected.routes) {
369
+ const key = routeKey(want.method, want.path)
370
+ expectedKeys.add(key)
371
+ const got = actualByKey.get(key)
372
+ if (got === undefined) {
373
+ drift.push({ route: key, message: "route missing after import" })
374
+ continue
375
+ }
376
+ const wantQuery = new Map(want.query.map((param) => [param.name, param.required]))
377
+ const gotQuery = new Map(got.query.map((param) => [param.name, param.required]))
378
+ for (const [name, required] of wantQuery) {
379
+ if (!gotQuery.has(name)) drift.push({ route: key, message: `query parameter '${name}' lost` })
380
+ else if (gotQuery.get(name) !== required)
381
+ drift.push({ route: key, message: `query parameter '${name}' required flag flipped` })
382
+ }
383
+ for (const name of gotQuery.keys()) {
384
+ if (!wantQuery.has(name))
385
+ drift.push({ route: key, message: `unexpected query parameter '${name}'` })
386
+ }
387
+ if (want.hasBody !== got.hasBody)
388
+ drift.push({
389
+ route: key,
390
+ message: want.hasBody ? "request body lost" : "unexpected request body",
391
+ })
392
+ else if (want.hasBody && want.requestBodyRequired !== got.requestBodyRequired)
393
+ drift.push({ route: key, message: "request body required flag flipped" })
394
+ const wantStatuses = new Set(want.responseStatuses)
395
+ const gotStatuses = new Set(got.responseStatuses)
396
+ for (const status of wantStatuses) {
397
+ if (!gotStatuses.has(status))
398
+ drift.push({ route: key, message: `response status ${status} lost` })
399
+ }
400
+ for (const status of gotStatuses) {
401
+ if (!wantStatuses.has(status))
402
+ drift.push({ route: key, message: `unexpected response status ${status}` })
403
+ }
404
+ }
405
+ for (const got of actual.routes) {
406
+ const key = routeKey(got.method, got.path)
407
+ if (!expectedKeys.has(key)) drift.push({ route: key, message: "unexpected route after import" })
408
+ }
409
+ return Object.freeze(drift)
410
+ }