@znight/openapi 0.0.0-stage → 0.1.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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +572 -2
  3. package/dist/build-BTUC96mY.d.mts +174 -0
  4. package/dist/build-DXunOzlI.mjs +733 -0
  5. package/dist/build-DXunOzlI.mjs.map +1 -0
  6. package/dist/compiler.d.mts +16 -0
  7. package/dist/compiler.mjs +2639 -0
  8. package/dist/compiler.mjs.map +1 -0
  9. package/dist/emit-child.d.mts +1 -0
  10. package/dist/emit-child.mjs +124 -0
  11. package/dist/emit-child.mjs.map +1 -0
  12. package/dist/emit.d.mts +2 -0
  13. package/dist/emit.mjs +252 -0
  14. package/dist/emit.mjs.map +1 -0
  15. package/dist/index-CqQ6IRnN.d.mts +60 -0
  16. package/dist/index.d.mts +260 -0
  17. package/dist/index.mjs +777 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/openapi-CQk5dHqc.mjs +39 -0
  20. package/dist/openapi-CQk5dHqc.mjs.map +1 -0
  21. package/dist/shared-BZxtyaB0.mjs +147 -0
  22. package/dist/shared-BZxtyaB0.mjs.map +1 -0
  23. package/dist/testing.d.mts +39 -0
  24. package/dist/testing.mjs +155 -0
  25. package/dist/testing.mjs.map +1 -0
  26. package/dist/types-VzBexMKj.d.mts +213 -0
  27. package/dist/vite.d.mts +24 -0
  28. package/dist/vite.mjs +64 -0
  29. package/dist/vite.mjs.map +1 -0
  30. package/package.json +115 -4
  31. package/src/build.ts +898 -0
  32. package/src/compiler/analyze.ts +81 -0
  33. package/src/compiler/document.ts +612 -0
  34. package/src/compiler/generate.ts +79 -0
  35. package/src/compiler/index.ts +47 -0
  36. package/src/compiler/model.ts +141 -0
  37. package/src/compiler/operations.ts +1475 -0
  38. package/src/compiler/schemas.ts +706 -0
  39. package/src/compiler/validate.ts +102 -0
  40. package/src/compiler/values.ts +81 -0
  41. package/src/converters.ts +137 -0
  42. package/src/decorators.ts +187 -0
  43. package/src/emit/child.ts +144 -0
  44. package/src/emit/index.ts +377 -0
  45. package/src/emit/protocol.ts +35 -0
  46. package/src/index.ts +94 -0
  47. package/src/metadata.ts +112 -0
  48. package/src/openapi.ts +55 -0
  49. package/src/plugin.ts +810 -0
  50. package/src/shared.ts +154 -0
  51. package/src/testing.ts +224 -0
  52. package/src/types.ts +240 -0
  53. package/src/ui.ts +215 -0
  54. package/src/vite.ts +126 -0
package/src/build.ts ADDED
@@ -0,0 +1,898 @@
1
+ /**
2
+ * `buildOpenApiDocument` (§7.3 of RFC 0002, normative): assembles the OpenAPI document from
3
+ * `metadata.extensions.openapi`. The same pure function runs at boot (`OpenApiPlugin`), at build time
4
+ * (`znight openapi`, `znight build --openapi`, the `openapi()` Vite plugin) and in tests, so the
5
+ * served document and the emitted file are byte-identical.
6
+ */
7
+ import type { ZnightMetadata } from '@znight/core'
8
+ import type { StandardSchemaV1 } from '@znight/http'
9
+ import type { SchemaConverter } from './converters.ts'
10
+ import type { OpenApiMetadata, OpenApiSchemaSlot } from './metadata.ts'
11
+ import type {
12
+ InfoObject,
13
+ JsonSchema,
14
+ JsonValue,
15
+ OpenApiMethod,
16
+ OpenApiObject,
17
+ ServerObject,
18
+ TagObject,
19
+ } from './types.ts'
20
+
21
+ import { displayName, ErrorCode, ZnightError } from '@znight/core'
22
+
23
+ import { BUILT_IN_CONVERTERS, vendorOf } from './converters.ts'
24
+ import { OPENAPI_METADATA_VERSION } from './metadata.ts'
25
+ import {
26
+ compareStrings,
27
+ componentName,
28
+ ERROR_COMPONENTS,
29
+ ERROR_RESPONSES,
30
+ jsonEquals,
31
+ reasonPhrase,
32
+ schemaRef,
33
+ } from './shared.ts'
34
+
35
+ /** A static view of an operation (no schema conversion), used by `filter` and `OpenApi.operations`. */
36
+ export interface OpenApiOperationView {
37
+ readonly method: OpenApiMethod
38
+ /** Path template without the HTTP prefix (`/books/{id}`). */
39
+ readonly path: string
40
+ readonly operationId: string
41
+ readonly tags: readonly string[]
42
+ /** Controller display name. */
43
+ readonly controller: string
44
+ readonly handler: string
45
+ /** Effective route metadata (`createRouteDecorator` values, the nearest decision of each group). */
46
+ readonly meta: Readonly<Record<string, unknown>>
47
+ }
48
+
49
+ /** Options of {@link buildOpenApiDocument}. */
50
+ export interface BuildOpenApiOptions {
51
+ /** The HTTP prefix: `servers` defaults to `[{ url: prefix }]` when nothing declares servers. */
52
+ readonly prefix?: string
53
+ /** Merged over the static `info`. */
54
+ readonly info?: Partial<InfoObject>
55
+ /** Replaces the declared `servers`. */
56
+ readonly servers?: readonly ServerObject[]
57
+ /** Hides operations; unreferenced components are pruned. */
58
+ readonly filter?: (operation: OpenApiOperationView) => boolean
59
+ /** Last-resort escape hatch, applied before freezing. */
60
+ readonly transform?: (document: OpenApiObject) => OpenApiObject
61
+ /** Extra converters, tried before the built-in ones. */
62
+ readonly converters?: readonly SchemaConverter[]
63
+ /** `'warn'`: a schema that cannot be converted becomes `{}` with a ZNIGHT037 warning. Default `'error'`. */
64
+ readonly unrepresentable?: 'error' | 'warn'
65
+ }
66
+
67
+ /** A warning of the assembly. */
68
+ export interface OpenApiWarning {
69
+ readonly code: ErrorCode
70
+ readonly message: string
71
+ readonly hints: readonly string[]
72
+ }
73
+
74
+ /** Result of {@link buildOpenApiDocument}. */
75
+ export interface OpenApiBuildResult {
76
+ /** The document, deep-frozen. */
77
+ readonly document: OpenApiObject
78
+ /** `JSON.stringify(document, null, 2)` and a final newline. */
79
+ readonly json: string
80
+ readonly warnings: readonly OpenApiWarning[]
81
+ readonly stats: { readonly operations: number; readonly schemas: number; readonly durationMs: number }
82
+ }
83
+
84
+ type Json = Record<string, unknown>
85
+
86
+ /** Reads and checks `metadata.extensions.openapi` (ZNIGHT030 when missing, ZNIGHT018 for another version). */
87
+ export function openApiSection(metadata: ZnightMetadata): OpenApiMetadata {
88
+ const section = (metadata?.extensions as { openapi?: unknown } | undefined)?.openapi
89
+ if (section === undefined || section === null || typeof section !== 'object') {
90
+ throw new ZnightError(
91
+ ErrorCode.InvalidOpenApiDefinition,
92
+ 'The metadata has no OpenAPI section (metadata.extensions.openapi)',
93
+ {
94
+ hints: [
95
+ 'Import @znight/openapi in a scanned file (src/main.ts imports OpenApiPlugin) and regenerate the container (znight generate)',
96
+ ],
97
+ },
98
+ )
99
+ }
100
+ const version = (section as { version?: unknown }).version
101
+ if (version !== OPENAPI_METADATA_VERSION) {
102
+ throw new ZnightError(
103
+ ErrorCode.VersionMismatch,
104
+ `Unsupported metadata.extensions.openapi version ${JSON.stringify(version)} (this @znight/openapi reads version ${OPENAPI_METADATA_VERSION})`,
105
+ { hints: ['Use the same version of @znight/compiler and @znight/openapi, then regenerate the container'] },
106
+ )
107
+ }
108
+ const value = section as OpenApiMetadata
109
+ if (!Array.isArray(value.operations) || !Array.isArray(value.schemas) || typeof value.document !== 'object') {
110
+ throw new ZnightError(ErrorCode.InvalidOpenApiDefinition, 'Malformed metadata.extensions.openapi', {
111
+ hints: ['Regenerate the container (znight generate)'],
112
+ })
113
+ }
114
+ return value
115
+ }
116
+
117
+ /**
118
+ * Assembles the OpenAPI 3.1 document of an application from its generated metadata: converts the
119
+ * Standard Schemas (user converters, Standard JSON Schema, Valibot), hoists their `$defs`, names
120
+ * the components, fills the placeholders of the compiler, applies `filter`, prunes the unused
121
+ * components, applies `transform`, deep-freezes and serialises once.
122
+ *
123
+ * @throws ZnightError ZNIGHT030 (no OpenAPI section), ZNIGHT018 (another format version), ZNIGHT034
124
+ * (component name conflict), ZNIGHT037 (schema conversion, unless `unrepresentable: 'warn'`).
125
+ */
126
+ export async function buildOpenApiDocument(
127
+ metadata: ZnightMetadata,
128
+ options: BuildOpenApiOptions = {},
129
+ ): Promise<OpenApiBuildResult> {
130
+ const started = performance.now()
131
+ const section = openApiSection(metadata)
132
+ const warnings: OpenApiWarning[] = []
133
+ const lenient = options.unrepresentable === 'warn'
134
+ const converters = [...(options.converters ?? []), ...BUILT_IN_CONVERTERS]
135
+
136
+ // 1. conversion of the Standard Schemas
137
+ const failures: string[] = []
138
+ const converted = new Map<string, { input?: JsonSchema; output?: JsonSchema }>()
139
+ for (const slot of section.schemas) {
140
+ const entry: { input?: JsonSchema; output?: JsonSchema } = {}
141
+ for (const io of [...slot.io].sort()) {
142
+ const result = await convertSlot(slot, io, converters, false)
143
+ if (result.schema !== undefined) {
144
+ entry[io] = result.schema
145
+ continue
146
+ }
147
+ if (!lenient) {
148
+ failures.push(result.message)
149
+ continue
150
+ }
151
+ const retried = await convertSlot(slot, io, converters, true)
152
+ entry[io] = retried.schema ?? {}
153
+ warnings.push({
154
+ code: ErrorCode.SchemaConversionFailed,
155
+ message: `${result.message}; documented as ${retried.schema === undefined ? 'any value ({})' : 'an approximation'}`,
156
+ hints: [],
157
+ })
158
+ }
159
+ converted.set(slot.name, entry)
160
+ }
161
+ if (failures.length > 0) {
162
+ throw new ZnightError(
163
+ ErrorCode.SchemaConversionFailed,
164
+ `Cannot convert ${failures.length === 1 ? 'a schema' : `${failures.length} schemas`} to JSON Schema:\n${failures.map(line => ` - ${line}`).join('\n')}`,
165
+ {
166
+ hints: [
167
+ 'Add a converter (OpenApiPlugin.configure({ converters }), --converters), or document the schema with unrepresentable: "warn"',
168
+ ],
169
+ },
170
+ )
171
+ }
172
+
173
+ // 2. names of the converted schemas
174
+ const schemas = new Map<string, JsonSchema>()
175
+ const owners = new Map<string, string>()
176
+ const define = (name: string, schema: JsonSchema, owner: string): void => {
177
+ const existing = schemas.get(name)
178
+ if (existing === undefined) {
179
+ schemas.set(name, schema)
180
+ owners.set(name, owner)
181
+ return
182
+ }
183
+ if (jsonEquals(existing, schema)) return
184
+ throw new ZnightError(
185
+ ErrorCode.SchemaNameConflict,
186
+ `Two different schemas are named "${name}" in the OpenAPI document (${owners.get(name)} and ${owner})`,
187
+ { hints: ['Rename one of them (exported schema names and ids of hoisted definitions name the components)'] },
188
+ )
189
+ }
190
+ for (const [name, schema] of Object.entries(section.components ?? {}))
191
+ define(name, schema as JsonSchema, 'a TypeScript declaration')
192
+ const named = nameConverted(section.schemas, converted)
193
+ for (const item of named.components) define(item.name, item.schema, item.owner)
194
+ const slotNames = named.slots
195
+
196
+ // 3. standard errors
197
+ const errors = section.errors
198
+ const responses: Json = {}
199
+ if (errors !== undefined) {
200
+ let body: string
201
+ let validation: string
202
+ if (errors.schema === undefined) {
203
+ define(ERROR_COMPONENTS.body, HTTP_ERROR_BODY, 'the standard errors')
204
+ define(ERROR_COMPONENTS.validation, VALIDATION_ERROR_BODY, 'the standard errors')
205
+ body = ERROR_COMPONENTS.body
206
+ validation = ERROR_COMPONENTS.validation
207
+ } else {
208
+ body = slotNames.get(errors.schema)?.output ?? errors.schema
209
+ validation = body
210
+ }
211
+ const response = (status: string, name: string): Json => ({
212
+ description: reasonPhrase(status),
213
+ content: { [errors.contentType]: { schema: { $ref: schemaRef(name) } } },
214
+ })
215
+ responses[ERROR_RESPONSES.badRequest] = response('400', validation)
216
+ responses[ERROR_RESPONSES.payloadTooLarge] = response('413', body)
217
+ responses[ERROR_RESPONSES.unsupportedMediaType] = response('415', body)
218
+ }
219
+
220
+ // 4. placeholders
221
+ const duplicates: string[] = []
222
+ const resolve = (value: unknown, where = ''): unknown =>
223
+ replacePlaceholders(value, slotNames, schemas, (parameter, slot) =>
224
+ duplicates.push(`${where}: the ${parameter.in} parameter "${parameter.name}" of the schema ${slot}`),
225
+ )
226
+ for (const [name, schema] of schemas) schemas.set(name, resolve(schema) as JsonSchema)
227
+
228
+ // 5. operations, filter
229
+ const views = operationViews(metadata, section)
230
+ const paths: Record<string, Json> = {}
231
+ let operations = 0
232
+ section.operations.forEach((definition, index) => {
233
+ const view = views[index] as OpenApiOperationView
234
+ if (options.filter !== undefined && !options.filter(view)) return
235
+ let item = paths[definition.path]
236
+ if (item === undefined) {
237
+ item = {}
238
+ paths[definition.path] = item
239
+ }
240
+ item[definition.method] = resolve(definition.operation, `${definition.method.toUpperCase()} ${definition.path}`)
241
+ operations++
242
+ })
243
+ if (duplicates.length > 0) {
244
+ warnings.push({
245
+ code: ErrorCode.InconsistentOperation,
246
+ message: `${duplicates.length === 1 ? 'A parameter expanded from a whole-object schema is' : 'Parameters expanded from whole-object schemas are'} already documented (OpenAPI forbids two parameters with one name and location), the duplicate is dropped:\n${duplicates.map(line => ` - ${line}`).join('\n')}`,
247
+ hints: ["Remove the property from the schema, or the @Query('name') / @ApiQuery('name') that reads it again"],
248
+ })
249
+ }
250
+
251
+ // 6. document
252
+ const template = section.document
253
+ const document: Json = { openapi: template.openapi }
254
+ document['info'] = options.info === undefined ? template.info : { ...template.info, ...options.info }
255
+ const servers =
256
+ options.servers ??
257
+ template.servers ??
258
+ (options.prefix !== undefined && options.prefix !== '' && options.prefix !== '/'
259
+ ? [{ url: options.prefix }]
260
+ : undefined)
261
+ if (servers !== undefined) document['servers'] = servers
262
+ if (template.security !== undefined) document['security'] = template.security
263
+ const tags = tagsOf(template.tags ?? [], paths, section.tagDescriptions ?? {})
264
+ if (tags.length > 0) document['tags'] = tags
265
+ if (template.externalDocs !== undefined) document['externalDocs'] = template.externalDocs
266
+ document['paths'] = paths
267
+ const components = prune(document, {
268
+ schemas: Object.fromEntries([...schemas].sort(([a], [b]) => compareStrings(a, b))),
269
+ responses,
270
+ securitySchemes: section.securitySchemes ?? {},
271
+ })
272
+ if (Object.keys(components).length > 0) document['components'] = components
273
+ for (const [key, value] of Object.entries(template)) {
274
+ if (key.startsWith('x-') && value !== undefined) document[key] = value
275
+ }
276
+ document['x-znight-fingerprint'] = section.fingerprint
277
+
278
+ // 7. output
279
+ const transformed =
280
+ options.transform === undefined
281
+ ? (document as unknown as OpenApiObject)
282
+ : options.transform(document as unknown as OpenApiObject)
283
+ const json = `${JSON.stringify(transformed, null, 2)}\n`
284
+ const frozen = deepFreeze(JSON.parse(json) as OpenApiObject)
285
+ const componentCount = Object.keys((frozen.components?.schemas ?? {}) as object).length
286
+ return {
287
+ document: frozen,
288
+ json,
289
+ warnings,
290
+ stats: { operations, schemas: componentCount, durationMs: performance.now() - started },
291
+ }
292
+ }
293
+
294
+ /** Root keys of a document, in the order {@link buildOpenApiDocument} writes them. */
295
+ const ROOT_ORDER = ['openapi', 'info', 'servers', 'security', 'tags', 'externalDocs', 'paths', 'components']
296
+
297
+ /**
298
+ * Applies the runtime options of {@link buildOpenApiDocument} to a prebuilt document (`OpenApiPlugin`
299
+ * with `document: { file }`), so that a file and a build serve the same document: `filter` (the
300
+ * operations are matched by path and method, unreferenced components and unused tags are pruned),
301
+ * `info`, `servers`, else the `prefix` rule when the file declares no servers, then `transform`.
302
+ * `undefined` when there is nothing to apply (the file is served as is).
303
+ */
304
+ export function applyDocumentOptions(
305
+ text: string,
306
+ metadata: ZnightMetadata,
307
+ options: Pick<BuildOpenApiOptions, 'filter' | 'info' | 'servers' | 'transform' | 'prefix'>,
308
+ ): { document: OpenApiObject; json: string } | undefined {
309
+ const document = JSON.parse(text) as Json
310
+ const prefixed =
311
+ document['servers'] === undefined && options.prefix !== undefined && options.prefix !== '' && options.prefix !== '/'
312
+ if (
313
+ options.filter === undefined &&
314
+ options.info === undefined &&
315
+ options.servers === undefined &&
316
+ options.transform === undefined &&
317
+ !prefixed
318
+ ) {
319
+ return undefined
320
+ }
321
+ if (options.filter !== undefined) filterDocument(document, metadata, options.filter)
322
+ if (options.info !== undefined) document['info'] = { ...(document['info'] as Json), ...options.info }
323
+ if (options.servers !== undefined) document['servers'] = options.servers
324
+ else if (prefixed) document['servers'] = [{ url: options.prefix }]
325
+ const ordered: Json = {}
326
+ for (const key of ROOT_ORDER) if (document[key] !== undefined) ordered[key] = document[key]
327
+ for (const [key, value] of Object.entries(document)) if (!(key in ordered)) ordered[key] = value
328
+ const transformed =
329
+ options.transform === undefined
330
+ ? (ordered as unknown as OpenApiObject)
331
+ : options.transform(ordered as unknown as OpenApiObject)
332
+ const json = `${JSON.stringify(transformed, null, 2)}\n`
333
+ return { document: deepFreeze(JSON.parse(json) as OpenApiObject), json }
334
+ }
335
+
336
+ /** Removes the operations `filter` rejects, the paths left empty, the unused tags and components. */
337
+ function filterDocument(
338
+ document: Json,
339
+ metadata: ZnightMetadata,
340
+ filter: (operation: OpenApiOperationView) => boolean,
341
+ ): void {
342
+ const section = openApiSection(metadata)
343
+ const views = new Map<string, OpenApiOperationView>()
344
+ for (const view of operationViews(metadata, section)) views.set(`${view.method} ${view.path}`, view)
345
+ const paths: Record<string, Json> = {}
346
+ for (const [path, item] of Object.entries((document['paths'] ?? {}) as Record<string, Json>)) {
347
+ const kept: Json = {}
348
+ let methods = 0
349
+ for (const [key, value] of Object.entries(item)) {
350
+ if (METHODS.has(key)) {
351
+ // an operation unknown to this build (stale file) is kept: the filter cannot judge it
352
+ const view = views.get(`${key} ${path}`)
353
+ if (view !== undefined && !filter(view)) continue
354
+ methods++
355
+ }
356
+ kept[key] = value
357
+ }
358
+ if (methods > 0) paths[path] = kept
359
+ }
360
+ document['paths'] = paths
361
+ if (Array.isArray(document['tags'])) {
362
+ const declared = new Set((section.document.tags ?? []).map(tag => tag.name))
363
+ const used = new Set(tagsOf([], paths, {}).map(tag => tag.name))
364
+ const tags = (document['tags'] as TagObject[]).filter(tag => declared.has(tag.name) || used.has(tag.name))
365
+ if (tags.length > 0) document['tags'] = tags
366
+ else delete document['tags']
367
+ }
368
+ const components = (document['components'] ?? {}) as Json
369
+ delete document['components']
370
+ const pruned = prune(document, {
371
+ schemas: (components['schemas'] ?? {}) as Json,
372
+ responses: (components['responses'] ?? {}) as Json,
373
+ securitySchemes: (components['securitySchemes'] ?? {}) as Json,
374
+ })
375
+ const result: Json = {}
376
+ for (const [kind, value] of Object.entries(components)) {
377
+ if (kind !== 'schemas' && kind !== 'responses' && kind !== 'securitySchemes') result[kind] = value
378
+ else if (pruned[kind] !== undefined) result[kind] = pruned[kind]
379
+ }
380
+ if (Object.keys(result).length > 0) document['components'] = result
381
+ }
382
+
383
+ const METHODS: ReadonlySet<string> = new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'])
384
+
385
+ async function convertSlot(
386
+ slot: OpenApiSchemaSlot,
387
+ io: 'input' | 'output',
388
+ converters: readonly SchemaConverter[],
389
+ lenient: boolean,
390
+ ): Promise<{ schema: JsonSchema | undefined; message: string }> {
391
+ const where = slot.source === undefined ? '' : ` (${slot.source.file}:${slot.source.line}:${slot.source.column})`
392
+ const label = `${slot.name}${where}, ${vendorOf(slot.schema)} schema, ${io}`
393
+ for (const converter of converters) {
394
+ let result: unknown
395
+ try {
396
+ result = await converter.convert(slot.schema as StandardSchemaV1, { io, target: 'draft-2020-12', lenient })
397
+ } catch (error) {
398
+ return {
399
+ schema: undefined,
400
+ message: `${label}: the converter "${converter.name}" failed: ${error instanceof Error ? error.message : String(error)}`,
401
+ }
402
+ }
403
+ if (result === undefined) continue
404
+ if (typeof result !== 'object' || result === null || Array.isArray(result)) {
405
+ return {
406
+ schema: undefined,
407
+ message: `${label}: the converter "${converter.name}" returned ${Array.isArray(result) ? 'an array' : typeof result} instead of a JSON Schema object`,
408
+ }
409
+ }
410
+ return { schema: result as JsonSchema, message: '' }
411
+ }
412
+ return {
413
+ schema: undefined,
414
+ message: `${label}: no converter supports it (the library implements no Standard JSON Schema)`,
415
+ }
416
+ }
417
+
418
+ /** Drops `$schema` and `$id` (top level). */
419
+ function clean(schema: JsonSchema): JsonSchema {
420
+ const { $schema: _schema, $id: _id, ...rest } = schema
421
+ return JSON.parse(JSON.stringify(rest)) as JsonSchema
422
+ }
423
+
424
+ /**
425
+ * Definition names a library generates for anonymous sub-schemas (Zod: `__schema0` for a recursive
426
+ * schema without id). They are local to one conversion, so they are scoped to their slot.
427
+ */
428
+ const ANONYMOUS_DEFINITION = /^__schema(\d+)$/
429
+
430
+ /** Marks the references between definitions until the final names are known (`\0<name>\0<rest>`). */
431
+ const MARK = '\u0000'
432
+
433
+ /** A JSON Schema of one slot and one io, split into its root and its local definitions. */
434
+ interface ConvertedUnit {
435
+ readonly slot: string
436
+ readonly io: 'input' | 'output'
437
+ /** Global names of the bodies (the root is the slot name), references marked. */
438
+ readonly bodies: { readonly name: string; readonly body: unknown; readonly owner: string }[]
439
+ }
440
+
441
+ /**
442
+ * Names the converted schemas (steps 3 and 4 of §7.3): hoists `$defs` / `definitions` into
443
+ * components, scopes anonymous definitions to their slot (`Tree_schema0`), takes the definition as
444
+ * the slot component when the root only references it (`.meta({ id })` equal to the export name),
445
+ * and gives `<Name>Input` to the input variant of every schema — slot or definition — whose input
446
+ * and output JSON Schemas differ (directly or through the definitions it references). Two different
447
+ * schemas of one io under one name are ZNIGHT034.
448
+ */
449
+ function nameConverted(
450
+ slots: readonly OpenApiSchemaSlot[],
451
+ converted: ReadonlyMap<string, { input?: JsonSchema; output?: JsonSchema }>,
452
+ ): {
453
+ components: { name: string; schema: JsonSchema; owner: string }[]
454
+ slots: Map<string, { input?: string; output?: string }>
455
+ } {
456
+ const units: ConvertedUnit[] = []
457
+ for (const slot of slots) {
458
+ const entry = converted.get(slot.name) ?? {}
459
+ for (const io of ['output', 'input'] as const) {
460
+ const schema = entry[io]
461
+ if (schema !== undefined) units.push(splitUnit(slot.name, io, clean(schema)))
462
+ }
463
+ }
464
+ // variants of every global name, by io
465
+ const variants = new Map<string, { input?: unknown; output?: unknown; owners: Partial<Record<string, string>> }>()
466
+ for (const unit of units) {
467
+ for (const { name, body, owner } of unit.bodies) {
468
+ let entry = variants.get(name)
469
+ if (entry === undefined) {
470
+ entry = { owners: {} }
471
+ variants.set(name, entry)
472
+ }
473
+ const existing = entry[unit.io]
474
+ if (existing === undefined) {
475
+ entry[unit.io] = body
476
+ entry.owners[unit.io] = owner
477
+ } else if (!jsonEquals(existing, body)) {
478
+ throw new ZnightError(
479
+ ErrorCode.SchemaNameConflict,
480
+ `Two different schemas are named "${name}" in the OpenAPI document (${entry.owners[unit.io]} and ${owner})`,
481
+ { hints: ['Rename one of them (exported schema names and ids of hoisted definitions name the components)'] },
482
+ )
483
+ }
484
+ }
485
+ }
486
+ // `<Name>Input` when the input variant differs, directly or through a definition it references
487
+ const split = new Set<string>()
488
+ for (const [name, entry] of variants) {
489
+ if (entry.input !== undefined && entry.output !== undefined && !jsonEquals(entry.input, entry.output)) {
490
+ split.add(name)
491
+ }
492
+ }
493
+ for (let changed = true; changed; ) {
494
+ changed = false
495
+ for (const [name, entry] of variants) {
496
+ if (split.has(name) || entry.input === undefined || entry.output === undefined) continue
497
+ if ([...referencedNames(entry.input)].some(target => split.has(target))) {
498
+ split.add(name)
499
+ changed = true
500
+ }
501
+ }
502
+ }
503
+ const nameFor = (name: string, io: 'input' | 'output'): string =>
504
+ io === 'input' && split.has(name) ? `${name}Input` : name
505
+ const components: { name: string; schema: JsonSchema; owner: string }[] = []
506
+ for (const [name, entry] of variants) {
507
+ if (entry.output !== undefined) {
508
+ components.push({
509
+ name,
510
+ schema: resolveMarks(entry.output, 'output', nameFor) as JsonSchema,
511
+ owner: entry.owners['output'] as string,
512
+ })
513
+ }
514
+ if (entry.input !== undefined && (entry.output === undefined || split.has(name))) {
515
+ components.push({
516
+ name: nameFor(name, 'input'),
517
+ schema: resolveMarks(entry.input, 'input', nameFor) as JsonSchema,
518
+ owner: entry.owners['input'] as string,
519
+ })
520
+ }
521
+ }
522
+ const names = new Map<string, { input?: string; output?: string }>()
523
+ for (const slot of slots) {
524
+ const entry = converted.get(slot.name) ?? {}
525
+ const result: { input?: string; output?: string } = {}
526
+ if (entry.output !== undefined) result.output = nameFor(slot.name, 'output')
527
+ if (entry.input !== undefined) result.input = nameFor(slot.name, 'input')
528
+ names.set(slot.name, result)
529
+ }
530
+ return { components, slots: names }
531
+ }
532
+
533
+ /**
534
+ * Splits a converted schema into its root and its `$defs` / `definitions` (at every level), with
535
+ * global names: the slot name for the root, `<slot>_schemaN` for anonymous definitions, the
536
+ * definition name (a library id) otherwise. Inside an OpenAPI document `#` is the document, not the
537
+ * schema, so every local reference is rewritten (`#/$defs/X`, `#`, `#/properties/...`).
538
+ */
539
+ function splitUnit(slot: string, io: 'input' | 'output', schema: JsonSchema): ConvertedUnit {
540
+ const defs = new Map<string, unknown>()
541
+ const collect = (value: unknown): void => {
542
+ if (typeof value !== 'object' || value === null) return
543
+ if (Array.isArray(value)) {
544
+ for (const item of value) collect(item)
545
+ return
546
+ }
547
+ for (const key of ['$defs', 'definitions']) {
548
+ const block = (value as Json)[key]
549
+ if (typeof block === 'object' && block !== null && !Array.isArray(block)) {
550
+ for (const [defName, def] of Object.entries(block)) defs.set(defName, def)
551
+ }
552
+ }
553
+ for (const [key, item] of Object.entries(value)) if (key !== '$defs' && key !== 'definitions') collect(item)
554
+ }
555
+ collect(schema)
556
+ const globals = new Map<string, string>()
557
+ for (const local of defs.keys()) {
558
+ const anonymous = ANONYMOUS_DEFINITION.exec(local)
559
+ globals.set(local, anonymous === null ? componentName(local) : componentName(`${slot}_schema${anonymous[1]}`))
560
+ }
561
+ let root: unknown = schema
562
+ // `{ $ref: '#/$defs/X', $defs: { X } }` (Zod `.meta({ id })` on the root): X is the slot itself
563
+ const keys = Object.keys(schema).filter(key => key !== '$defs' && key !== 'definitions')
564
+ const target = keys.length === 1 && typeof schema['$ref'] === 'string' ? localTarget(schema['$ref']) : undefined
565
+ if (target !== undefined && target.rest === '' && defs.has(target.local)) {
566
+ const global = globals.get(target.local) as string
567
+ if (global === slot || ANONYMOUS_DEFINITION.test(target.local)) {
568
+ root = defs.get(target.local)
569
+ defs.delete(target.local)
570
+ globals.set(target.local, slot)
571
+ }
572
+ }
573
+ const rewrite = (value: unknown): unknown => {
574
+ if (Array.isArray(value)) return value.map(rewrite)
575
+ if (typeof value !== 'object' || value === null) return value
576
+ const result: Json = {}
577
+ for (const [key, item] of Object.entries(value)) {
578
+ if (key === '$defs' || key === 'definitions') continue
579
+ if (key === '$ref' && typeof item === 'string') result[key] = markRef(item, slot, globals)
580
+ else result[key] = rewrite(item)
581
+ }
582
+ return result
583
+ }
584
+ const bodies = [{ name: slot, body: rewrite(root), owner: `the schema ${slot}` }]
585
+ for (const [local, def] of defs) {
586
+ bodies.push({ name: globals.get(local) as string, body: rewrite(def), owner: `a definition of ${slot}` })
587
+ }
588
+ return { slot, io, bodies }
589
+ }
590
+
591
+ /** The local definition a reference points to (`#/$defs/X/rest`), JSON pointer and URI escapes decoded. */
592
+ function localTarget(ref: string): { local: string; rest: string } | undefined {
593
+ for (const prefix of ['#/$defs/', '#/definitions/']) {
594
+ if (!ref.startsWith(prefix)) continue
595
+ const tail = ref.slice(prefix.length)
596
+ const slash = tail.indexOf('/')
597
+ const segment = slash === -1 ? tail : tail.slice(0, slash)
598
+ let local: string
599
+ try {
600
+ local = decodeURIComponent(segment)
601
+ } catch {
602
+ local = segment
603
+ }
604
+ return { local: local.replaceAll('~1', '/').replaceAll('~0', '~'), rest: slash === -1 ? '' : tail.slice(slash) }
605
+ }
606
+ return undefined
607
+ }
608
+
609
+ function markRef(ref: string, slot: string, globals: ReadonlyMap<string, string>): string {
610
+ const target = localTarget(ref)
611
+ if (target !== undefined) {
612
+ return `${MARK}${globals.get(target.local) ?? componentName(target.local)}${MARK}${target.rest}`
613
+ }
614
+ if (ref === '#') return `${MARK}${slot}${MARK}`
615
+ if (ref.startsWith('#/')) return `${MARK}${slot}${MARK}${ref.slice(1)}`
616
+ return ref
617
+ }
618
+
619
+ function parseMark(ref: string): { name: string; rest: string } | undefined {
620
+ if (!ref.startsWith(MARK)) return undefined
621
+ const end = ref.indexOf(MARK, 1)
622
+ return end === -1 ? undefined : { name: ref.slice(1, end), rest: ref.slice(end + 1) }
623
+ }
624
+
625
+ /** Global names referenced by a marked body. */
626
+ function referencedNames(value: unknown, found: Set<string> = new Set()): Set<string> {
627
+ if (Array.isArray(value)) {
628
+ for (const item of value) referencedNames(item, found)
629
+ } else if (typeof value === 'object' && value !== null) {
630
+ for (const [key, item] of Object.entries(value)) {
631
+ const mark = key === '$ref' && typeof item === 'string' ? parseMark(item) : undefined
632
+ if (mark === undefined) referencedNames(item, found)
633
+ else found.add(mark.name)
634
+ }
635
+ }
636
+ return found
637
+ }
638
+
639
+ /** Replaces the marked references with `#/components/schemas/<final name>`. */
640
+ function resolveMarks(
641
+ value: unknown,
642
+ io: 'input' | 'output',
643
+ nameFor: (name: string, io: 'input' | 'output') => string,
644
+ ): unknown {
645
+ if (Array.isArray(value)) return value.map(item => resolveMarks(item, io, nameFor))
646
+ if (typeof value !== 'object' || value === null) return value
647
+ const result: Json = {}
648
+ for (const [key, item] of Object.entries(value)) {
649
+ const mark = key === '$ref' && typeof item === 'string' ? parseMark(item) : undefined
650
+ result[key] =
651
+ mark === undefined ? resolveMarks(item, io, nameFor) : `${schemaRef(nameFor(mark.name, io))}${mark.rest}`
652
+ }
653
+ return result
654
+ }
655
+
656
+ function isPlaceholder(
657
+ value: unknown,
658
+ ): value is { $znight: string; name: string; io: 'input' | 'output'; in?: string } {
659
+ return typeof value === 'object' && value !== null && typeof (value as { $znight?: unknown }).$znight === 'string'
660
+ }
661
+
662
+ function replacePlaceholders(
663
+ value: unknown,
664
+ names: ReadonlyMap<string, { input?: string; output?: string }>,
665
+ schemas: ReadonlyMap<string, JsonSchema>,
666
+ duplicate: (parameter: { in: string; name: string }, slot: string) => void,
667
+ ): unknown {
668
+ if (Array.isArray(value)) {
669
+ if (!value.some(item => isPlaceholder(item) && item.$znight === 'parameters')) {
670
+ return value.map(item => replacePlaceholders(item, names, schemas, duplicate))
671
+ }
672
+ // a parameters array: OpenAPI forbids two parameters with one name and location, explicit ones win
673
+ const explicit = new Set<string>()
674
+ for (const item of value) {
675
+ const key = isPlaceholder(item) ? undefined : parameterKey(item)
676
+ if (key !== undefined) explicit.add(key)
677
+ }
678
+ const expanded = new Set<string>()
679
+ const result: unknown[] = []
680
+ for (const item of value) {
681
+ if (!isPlaceholder(item) || item.$znight !== 'parameters') {
682
+ result.push(replacePlaceholders(item, names, schemas, duplicate))
683
+ continue
684
+ }
685
+ for (const parameter of expandParameters(item, names, schemas)) {
686
+ const key = parameterKey(parameter) as string
687
+ if (explicit.has(key) || expanded.has(key)) {
688
+ duplicate(parameter as { in: string; name: string }, item.name)
689
+ continue
690
+ }
691
+ expanded.add(key)
692
+ result.push(parameter)
693
+ }
694
+ }
695
+ return result
696
+ }
697
+ if (typeof value !== 'object' || value === null) return value
698
+ if (isPlaceholder(value) && value.$znight === 'schema') {
699
+ const name = names.get(value.name)?.[value.io] ?? value.name
700
+ return { $ref: schemaRef(name) }
701
+ }
702
+ const result: Json = {}
703
+ for (const [key, item] of Object.entries(value)) result[key] = replacePlaceholders(item, names, schemas, duplicate)
704
+ return result
705
+ }
706
+
707
+ /** `in name` of a Parameter Object (header names are case-insensitive), `undefined` for a `$ref`. */
708
+ function parameterKey(parameter: unknown): string | undefined {
709
+ if (typeof parameter !== 'object' || parameter === null) return undefined
710
+ const { in: location, name } = parameter as { in?: unknown; name?: unknown }
711
+ if (typeof location !== 'string' || typeof name !== 'string') return undefined
712
+ return `${location} ${location === 'header' ? name.toLowerCase() : name}`
713
+ }
714
+
715
+ /** One parameter per property of the (input) object schema of a slot. */
716
+ function expandParameters(
717
+ placeholder: { name: string; in?: string },
718
+ names: ReadonlyMap<string, { input?: string; output?: string }>,
719
+ schemas: ReadonlyMap<string, JsonSchema>,
720
+ ): Json[] {
721
+ const name = names.get(placeholder.name)?.input ?? placeholder.name
722
+ let schema = schemas.get(name)
723
+ for (let depth = 0; depth < 8 && typeof schema?.['$ref'] === 'string'; depth++) {
724
+ schema = schemas.get(String(schema['$ref']).slice('#/components/schemas/'.length))
725
+ }
726
+ const properties = schema?.['properties']
727
+ if (schema === undefined || typeof properties !== 'object' || properties === null || Array.isArray(properties)) {
728
+ throw new ZnightError(
729
+ ErrorCode.SchemaConversionFailed,
730
+ `The ${placeholder.in ?? 'query'} schema ${placeholder.name} is not an object schema: its properties cannot become parameters`,
731
+ { hints: [`Validate the whole ${placeholder.in ?? 'query'} object with an object schema (z.object({...}))`] },
732
+ )
733
+ }
734
+ const required = new Set(Array.isArray(schema['required']) ? (schema['required'] as string[]) : [])
735
+ return Object.entries(properties).map(([property, propertySchema]) => {
736
+ const { description, ...rest } = propertySchema as Json
737
+ const parameter: Json = { name: property, in: placeholder.in ?? 'query' }
738
+ if (typeof description === 'string') parameter['description'] = description
739
+ if (placeholder.in === 'path' || required.has(property)) parameter['required'] = true
740
+ parameter['schema'] = rest
741
+ if ((rest['type'] === 'array' || Array.isArray(rest['items'])) && placeholder.in === 'query') {
742
+ parameter['style'] = 'form'
743
+ parameter['explode'] = true
744
+ }
745
+ return parameter
746
+ })
747
+ }
748
+
749
+ /** Declared tags first (in order), then the used tags, sorted. */
750
+ function tagsOf(
751
+ declared: readonly TagObject[],
752
+ paths: Readonly<Record<string, Json>>,
753
+ descriptions: Readonly<Record<string, string>>,
754
+ ): TagObject[] {
755
+ const tags: TagObject[] = [...declared]
756
+ const known = new Set(declared.map(tag => tag.name))
757
+ const used = new Set<string>()
758
+ for (const item of Object.values(paths)) {
759
+ for (const operation of Object.values(item)) {
760
+ for (const tag of ((operation as { tags?: unknown }).tags ?? []) as string[]) used.add(tag)
761
+ }
762
+ }
763
+ for (const tag of [...used].sort(compareStrings)) {
764
+ if (known.has(tag)) continue
765
+ const description = descriptions[tag]
766
+ tags.push(description === undefined ? { name: tag } : { name: tag, description })
767
+ }
768
+ return tags
769
+ }
770
+
771
+ /** Components referenced by the document (paths, security), transitively. */
772
+ function prune(document: Json, components: { schemas: Json; responses: Json; securitySchemes: Json }): Json {
773
+ const used = { schemas: new Set<string>(), responses: new Set<string>(), securitySchemes: new Set<string>() }
774
+ const visit = (value: unknown): void => {
775
+ if (Array.isArray(value)) {
776
+ for (const item of value) visit(item)
777
+ return
778
+ }
779
+ if (typeof value !== 'object' || value === null) return
780
+ for (const [key, item] of Object.entries(value)) {
781
+ if (key === '$ref' && typeof item === 'string') {
782
+ const match = /^#\/components\/(schemas|responses)\/([^/]+)/.exec(item)
783
+ const kind = match?.[1] as 'schemas' | 'responses' | undefined
784
+ const name =
785
+ match?.[2] === undefined
786
+ ? undefined
787
+ : decodeURIComponent(match[2].replaceAll('~1', '/').replaceAll('~0', '~'))
788
+ if (kind !== undefined && name !== undefined && !used[kind].has(name)) {
789
+ used[kind].add(name)
790
+ visit(components[kind][name])
791
+ }
792
+ } else if (key === 'security' && Array.isArray(item)) {
793
+ for (const requirement of item)
794
+ for (const scheme of Object.keys(requirement as object)) used.securitySchemes.add(scheme)
795
+ } else {
796
+ visit(item)
797
+ }
798
+ }
799
+ }
800
+ visit(document)
801
+ const result: Json = {}
802
+ for (const kind of ['schemas', 'responses', 'securitySchemes'] as const) {
803
+ const kept: Json = {}
804
+ for (const name of Object.keys(components[kind]).sort(compareStrings)) {
805
+ if (used[kind].has(name)) kept[name] = components[kind][name]
806
+ }
807
+ if (Object.keys(kept).length > 0) result[kind] = kept
808
+ }
809
+ return result
810
+ }
811
+
812
+ /** Views of the operations of the section (effective route metadata from `extensions.http`). */
813
+ export function operationViews(
814
+ metadata: ZnightMetadata,
815
+ section: OpenApiMetadata = openApiSection(metadata),
816
+ ): OpenApiOperationView[] {
817
+ const http = (metadata.extensions as { http?: HttpSectionLike } | undefined)?.http
818
+ const groups = new Map<string, string>()
819
+ for (const [group, definition] of Object.entries(http?.groups ?? {})) {
820
+ for (const key of definition.keys ?? []) groups.set(key, group)
821
+ }
822
+ const groupOf = (key: string): string => groups.get(key) ?? key
823
+ return section.operations.map(definition => {
824
+ const controller = http?.controllers?.find(candidate => candidate.token === definition.controller)
825
+ const route = controller?.routes?.find(
826
+ candidate => candidate.handler === definition.handler && candidate.method === definition.route,
827
+ )
828
+ const routeMeta = route?.meta ?? {}
829
+ const decided = new Set(Object.keys(routeMeta).map(groupOf))
830
+ const meta: Record<string, unknown> = {}
831
+ for (const [key, value] of Object.entries(controller?.meta ?? {})) if (!decided.has(groupOf(key))) meta[key] = value
832
+ Object.assign(meta, routeMeta)
833
+ const operation = definition.operation as { operationId?: string; tags?: readonly string[] }
834
+ return Object.freeze({
835
+ method: definition.method,
836
+ path: definition.path,
837
+ operationId: operation.operationId ?? '',
838
+ tags: Object.freeze([...(operation.tags ?? [])]),
839
+ controller: displayName(definition.controller),
840
+ handler: definition.handler,
841
+ meta: Object.freeze(meta),
842
+ })
843
+ })
844
+ }
845
+
846
+ /** The parts of `metadata.extensions.http` read for the effective metadata. */
847
+ interface HttpSectionLike {
848
+ readonly controllers?: readonly {
849
+ readonly token: string
850
+ readonly meta?: Readonly<Record<string, unknown>>
851
+ readonly routes?: readonly {
852
+ readonly method: string
853
+ readonly handler: string
854
+ readonly meta?: Readonly<Record<string, unknown>>
855
+ }[]
856
+ }[]
857
+ readonly groups?: Readonly<Record<string, { readonly keys?: readonly string[] }>>
858
+ }
859
+
860
+ function deepFreeze<T>(value: T): T {
861
+ if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {
862
+ Object.freeze(value)
863
+ for (const item of Object.values(value)) deepFreeze(item)
864
+ }
865
+ return value
866
+ }
867
+
868
+ /** `HttpErrorBody` (the body of every Znight error response). */
869
+ const HTTP_ERROR_BODY: JsonSchema = {
870
+ type: 'object',
871
+ properties: {
872
+ statusCode: { type: 'integer' },
873
+ error: { type: 'string' },
874
+ message: { type: 'string' },
875
+ details: {},
876
+ },
877
+ required: ['statusCode', 'error', 'message'],
878
+ }
879
+
880
+ /** `ValidationErrorBody`: a 400 body; validation failures list their issues in `details`. */
881
+ const VALIDATION_ERROR_BODY: JsonSchema = {
882
+ allOf: [
883
+ { $ref: schemaRef(ERROR_COMPONENTS.body) },
884
+ {
885
+ type: 'object',
886
+ properties: {
887
+ details: {
888
+ type: 'array',
889
+ items: {
890
+ type: 'object',
891
+ properties: { path: { type: 'string' }, message: { type: 'string' } },
892
+ required: ['path', 'message'],
893
+ },
894
+ },
895
+ },
896
+ },
897
+ ],
898
+ } satisfies Record<string, JsonValue>