@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
@@ -0,0 +1,1475 @@
1
+ /**
2
+ * The operation model (§5.4 of RFC 0002): one operation per documented route and concrete method,
3
+ * built from the route table of `@znight/http/compiler`, the handler signatures and their JSDoc,
4
+ * and the `@Api*` decorators.
5
+ */
6
+ import type {
7
+ ArgInfo,
8
+ ClassInfo,
9
+ DecoratorInfo,
10
+ DocComment,
11
+ Location,
12
+ MethodInfo,
13
+ ParameterInfo,
14
+ Shape,
15
+ StaticValue,
16
+ ValidateContext,
17
+ } from '@znight/compiler'
18
+ import type {
19
+ CoercionPlan,
20
+ HttpControllerData,
21
+ HttpParamData,
22
+ HttpRouteData,
23
+ MetaEntry,
24
+ UploadData,
25
+ } from '@znight/http/compiler'
26
+ import type { ParametersPlaceholder, SchemaTemplate } from '../metadata.ts'
27
+ import type { JsonSchema, JsonValue, SecurityRequirementObject } from '../types.ts'
28
+ import type { DecisionModel, DocumentModel, RequirementModel, SchemeRegistry } from './document.ts'
29
+ import type { OpenApiStored } from './model.ts'
30
+ import type { Schemas, SchemaUse, Slots } from './schemas.ts'
31
+
32
+ import {
33
+ compareStrings,
34
+ ERROR_COMPONENTS,
35
+ ERROR_RESPONSES,
36
+ HEADER_NAME,
37
+ isResponseStatus,
38
+ MEDIA_TYPE,
39
+ METHOD_ORDER,
40
+ OPERATION_ID,
41
+ reasonPhrase,
42
+ schemaRef,
43
+ } from '../shared.ts'
44
+ import { Codes, EXTENSION_NAME, HttpIds, Ids } from './model.ts'
45
+ import { describe, findReservedKey, isRecord, isRef, toJson } from './values.ts'
46
+
47
+ type Context = ValidateContext<OpenApiStored>
48
+ type Json = Record<string, unknown>
49
+
50
+ /** A built operation. */
51
+ export interface OperationModel {
52
+ readonly controller: string
53
+ readonly handler: string
54
+ readonly route: string
55
+ readonly method: string
56
+ readonly path: string
57
+ readonly operation: Json
58
+ readonly operationId: string
59
+ readonly label: string
60
+ readonly location: Location
61
+ }
62
+
63
+ /** Inputs shared by every operation. */
64
+ export interface OperationsInput {
65
+ readonly ctx: Context
66
+ readonly document: DocumentModel
67
+ readonly schemes: SchemeRegistry
68
+ readonly schemas: Schemas
69
+ readonly slots: Slots
70
+ }
71
+
72
+ const ALL_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const
73
+ const IGNORED_HEADERS = new Set(['accept', 'content-type', 'authorization'])
74
+
75
+ /** Builds every operation of the application (sorted by path, then method) and the tag descriptions. */
76
+ export function buildOperations(input: OperationsInput): {
77
+ operations: OperationModel[]
78
+ tagDescriptions: Record<string, string>
79
+ } {
80
+ const { ctx } = input
81
+ const http = ctx.graph.extensions.find(extension => extension.name === 'http')
82
+ const controllers = [...((http?.stored ?? []) as readonly HttpControllerData[])].sort((a, b) =>
83
+ compareStrings(a.token, b.token),
84
+ )
85
+ const explicit = new Set<string>()
86
+ for (const controller of controllers) {
87
+ for (const route of controller.routes) {
88
+ if (route.method !== 'ALL') explicit.add(`${route.method} ${pathShape(route.fullPath)}`)
89
+ }
90
+ }
91
+ const operations: OperationModel[] = []
92
+ const tagDescriptions: Record<string, string> = {}
93
+ for (const controller of controllers) {
94
+ const cls = ctx.classInfo(controller.identity)
95
+ const classDecorators = owned(cls?.decorators)
96
+ if (excluded(ctx, classDecorators, controller.displayName)) continue
97
+ const defaultTag = controller.displayName.replace(/Controller$/, '') || controller.displayName
98
+ const doc = input.document.jsdoc ? cls?.doc : undefined
99
+ const classTags = tagsOf(ctx, classDecorators)
100
+ if (classTags.length === 0 && doc !== undefined && doc.text !== '') tagDescriptions[defaultTag] = doc.text
101
+ for (const route of controller.routes) {
102
+ if (!route.complete) continue
103
+ const method = cls?.methods.find(candidate => candidate.name === route.handler && !candidate.static)
104
+ const methodDecorators = owned(method?.decorators)
105
+ if (excluded(ctx, methodDecorators, `${controller.displayName}.${route.handler}`)) continue
106
+ const methods =
107
+ route.method === 'ALL'
108
+ ? ALL_METHODS.filter(m => !explicit.has(`${m} ${pathShape(route.fullPath)}`))
109
+ : [route.method]
110
+ for (const httpMethod of methods) {
111
+ operations.push(
112
+ new OperationBuilder(input, {
113
+ controller,
114
+ route,
115
+ httpMethod,
116
+ cls,
117
+ method,
118
+ classDecorators,
119
+ methodDecorators,
120
+ classTags,
121
+ defaultTag,
122
+ }).build(),
123
+ )
124
+ }
125
+ }
126
+ }
127
+ checkDuplicateIds(ctx, operations)
128
+ unifyPathTemplates(ctx, operations)
129
+ for (const decision of input.document.decisions) {
130
+ if (decision.used) continue
131
+ ctx.report({
132
+ code: Codes.InvalidSecurityRequirement,
133
+ severity: 'warning',
134
+ message: `The security decision on ${decision.text} applies to no route`,
135
+ location: decision.location,
136
+ hints: ['Remove the decision, or apply the decorator to the routes it describes'],
137
+ })
138
+ }
139
+ operations.sort(
140
+ (a, b) => compareStrings(a.path, b.path) || METHOD_ORDER.indexOf(a.method) - METHOD_ORDER.indexOf(b.method),
141
+ )
142
+ return { operations, tagDescriptions }
143
+ }
144
+
145
+ /**
146
+ * `/books/{id}` from `/books/:id`; the trailing wildcard is `{wildcard}` (`{wildcard_1}`... when a
147
+ * parameter of the path is named `wildcard`).
148
+ */
149
+ export function pathTemplate(fullPath: string): string {
150
+ const wildcard = wildcardName(fullPath)
151
+ return fullPath
152
+ .split('/')
153
+ .map(segment => {
154
+ if (segment.startsWith(':')) return `{${segment.slice(1)}}`
155
+ return segment === '*' ? `{${wildcard}}` : segment
156
+ })
157
+ .join('/')
158
+ }
159
+
160
+ /** The name of the wildcard parameter of a path: `wildcard`, unless a `:wildcard` parameter exists. */
161
+ function wildcardName(fullPath: string): string {
162
+ const names = new Set(
163
+ fullPath
164
+ .split('/')
165
+ .filter(segment => segment.startsWith(':'))
166
+ .map(segment => segment.slice(1)),
167
+ )
168
+ let name = 'wildcard'
169
+ for (let index = 1; names.has(name); index++) name = `wildcard_${index}`
170
+ return name
171
+ }
172
+
173
+ /** Names of the parameters of a path template, in order. */
174
+ function templateNames(template: string): string[] {
175
+ return [...template.matchAll(/\{([^}]*)\}/g)].map(match => match[1] as string)
176
+ }
177
+
178
+ /**
179
+ * OpenAPI forbids two templates of one path (`/books/{id}` and `/books/{bookId}`): the operations of
180
+ * such a path are documented under one template (the first one, or the one a whole-path schema
181
+ * names), with a ZNIGHT035 warning.
182
+ */
183
+ function unifyPathTemplates(ctx: Context, operations: OperationModel[]): void {
184
+ const groups = new Map<string, number[]>()
185
+ operations.forEach((operation, index) => {
186
+ const shape = operation.path.replace(/\{[^}]*\}/g, '{}')
187
+ const group = groups.get(shape)
188
+ if (group === undefined) groups.set(shape, [index])
189
+ else group.push(index)
190
+ })
191
+ for (const indexes of groups.values()) {
192
+ const group = indexes.map(index => operations[index] as OperationModel)
193
+ const templates = [...new Set(group.map(operation => operation.path))].sort(compareStrings)
194
+ if (templates.length < 2) continue
195
+ const fixed = group.filter(hasPathPlaceholder).map(operation => operation.path)
196
+ const canonical = [...fixed].sort(compareStrings)[0] ?? (templates[0] as string)
197
+ const target = templateNames(canonical)
198
+ const renamed: OperationModel[] = []
199
+ for (const index of indexes) {
200
+ const operation = operations[index] as OperationModel
201
+ if (operation.path === canonical || hasPathPlaceholder(operation)) continue
202
+ const names = new Map(templateNames(operation.path).map((name, position) => [name, target[position] as string]))
203
+ for (const parameter of (operation.operation['parameters'] ?? []) as Json[]) {
204
+ const name = parameter['name']
205
+ if (parameter['in'] === 'path' && typeof name === 'string' && names.has(name)) {
206
+ parameter['name'] = names.get(name)
207
+ }
208
+ }
209
+ operations[index] = { ...operation, path: canonical }
210
+ renamed.push(operation)
211
+ }
212
+ const first = renamed[0] ?? (group[0] as OperationModel)
213
+ const kept = group.filter(operation => operation.path !== canonical && hasPathPlaceholder(operation))
214
+ ctx.report({
215
+ code: Codes.InconsistentOperation,
216
+ severity: 'warning',
217
+ message: `The routes of one path name its parameters differently (${group.map(operation => operation.label).join(', ')}): OpenAPI forbids two templates of one path, ${kept.length === 0 ? `documented as ${canonical}` : `the whole-path schemas keep ${kept.map(operation => operation.path).join(', ')}`}`,
218
+ location: first.location,
219
+ related: group
220
+ .filter(operation => operation !== first)
221
+ .map(operation => ({ message: operation.label, location: operation.location })),
222
+ hints: ['Give the parameters the same names on every route of the path'],
223
+ })
224
+ }
225
+ }
226
+
227
+ /** Whether the path parameters of an operation come from a whole-path schema (`@Param(Schema)`). */
228
+ function hasPathPlaceholder(operation: OperationModel): boolean {
229
+ return ((operation.operation['parameters'] ?? []) as Json[]).some(
230
+ parameter => parameter['$znight'] === 'parameters' && parameter['in'] === 'path',
231
+ )
232
+ }
233
+
234
+ function pathShape(fullPath: string): string {
235
+ return fullPath
236
+ .split('/')
237
+ .map(segment => (segment.startsWith(':') ? ':' : segment))
238
+ .join('/')
239
+ }
240
+
241
+ function owned(decorators: readonly DecoratorInfo[] | undefined): DecoratorInfo[] {
242
+ return (decorators ?? []).filter(decorator => decorator.owner === EXTENSION_NAME)
243
+ }
244
+
245
+ function excluded(ctx: Context, decorators: readonly DecoratorInfo[], target: string): boolean {
246
+ const exclude = decorators.find(decorator => decorator.identity === Ids.ApiExclude)
247
+ if (exclude === undefined) return false
248
+ const others = decorators.filter(decorator => decorator !== exclude)
249
+ if (others.length > 0) {
250
+ ctx.report({
251
+ code: Codes.InconsistentOperation,
252
+ severity: 'warning',
253
+ message: `${target} is excluded from the OpenAPI document (@ApiExclude): ${others.map(decorator => `@${decorator.name}`).join(', ')} ${others.length > 1 ? 'have' : 'has'} no effect`,
254
+ location: exclude.location,
255
+ hints: ['Remove @ApiExclude to document it, or remove the other @Api* decorators'],
256
+ })
257
+ }
258
+ return true
259
+ }
260
+
261
+ function tagsOf(ctx: Context, decorators: readonly DecoratorInfo[]): string[] {
262
+ const tags: string[] = []
263
+ for (const decorator of decorators.filter(candidate => candidate.identity === Ids.ApiTags)) {
264
+ for (const arg of decorator.args) {
265
+ if (typeof arg.value === 'string' && arg.value.trim() !== '') tags.push(arg.value)
266
+ else invalidValue(ctx, arg, 'a tag must be a non-empty string')
267
+ }
268
+ }
269
+ return [...new Set(tags)]
270
+ }
271
+
272
+ function invalidValue(
273
+ ctx: Context,
274
+ arg: ArgInfo | { readonly location: Location },
275
+ message: string,
276
+ hints: readonly string[] = [],
277
+ ): void {
278
+ ctx.report({ code: Codes.InvalidOpenApiDefinition, severity: 'error', message, location: arg.location, hints })
279
+ }
280
+
281
+ function checkDuplicateIds(ctx: Context, operations: readonly OperationModel[]): void {
282
+ const byId = new Map<string, OperationModel>()
283
+ for (const operation of operations) {
284
+ const other = byId.get(operation.operationId)
285
+ if (other === undefined) {
286
+ byId.set(operation.operationId, operation)
287
+ continue
288
+ }
289
+ ctx.report({
290
+ code: Codes.DuplicateOperationId,
291
+ severity: 'error',
292
+ message: `Duplicate operation id "${operation.operationId}": ${other.label} and ${operation.label}`,
293
+ location: operation.location,
294
+ related: [{ message: 'the other operation', location: other.location }],
295
+ hints: [`Rename one controller, or set an id: @ApiOperation({ operationId: '...' })`],
296
+ })
297
+ }
298
+ }
299
+
300
+ /** Everything one operation is built from. */
301
+ interface OperationSource {
302
+ readonly controller: HttpControllerData
303
+ readonly route: HttpRouteData
304
+ readonly httpMethod: string
305
+ readonly cls: ClassInfo | undefined
306
+ readonly method: MethodInfo | undefined
307
+ readonly classDecorators: readonly DecoratorInfo[]
308
+ readonly methodDecorators: readonly DecoratorInfo[]
309
+ readonly classTags: readonly string[]
310
+ readonly defaultTag: string
311
+ }
312
+
313
+ /** The success response inferred from the handler. */
314
+ interface SuccessModel {
315
+ readonly status: number
316
+ /** The status is chosen at runtime (`HttpResponse`, `@Res()`): other 2xx statuses may be declared. */
317
+ readonly dynamic: boolean
318
+ /** The status of an empty response also returned (`Book | undefined` → 204). */
319
+ readonly empty: number | undefined
320
+ readonly content: Record<string, Json> | undefined
321
+ readonly problem: string | undefined
322
+ readonly hints: string[]
323
+ readonly location: Location | undefined
324
+ }
325
+
326
+ /** Inferred or declared response of a status. */
327
+ interface ResponseDraft {
328
+ description: string | undefined
329
+ headers: Record<string, Json>
330
+ content: Record<string, Json> | undefined
331
+ }
332
+
333
+ class OperationBuilder {
334
+ readonly #input: OperationsInput
335
+ readonly #ctx: Context
336
+ readonly #source: OperationSource
337
+ readonly #label: string
338
+ readonly #location: Location
339
+ readonly #doc: DocComment | undefined
340
+ readonly #paramDocs: Map<string, string>
341
+ readonly #strict: boolean
342
+
343
+ constructor(input: OperationsInput, source: OperationSource) {
344
+ this.#input = input
345
+ this.#ctx = input.ctx
346
+ this.#source = source
347
+ this.#label = `${source.httpMethod} ${source.route.fullPath} (${source.controller.displayName}.${source.route.handler})`
348
+ this.#location = source.method?.location ?? source.route.location
349
+ this.#doc = input.document.jsdoc ? source.method?.doc : undefined
350
+ this.#paramDocs = new Map()
351
+ for (const tag of this.#doc?.tags ?? []) {
352
+ if (tag.name !== 'param') continue
353
+ const match = /^(?:\{[^}]*\}\s*)?\[?([\w$]+)[^\s]*\s*(?:-\s*)?([\s\S]*)$/.exec(tag.text)
354
+ if (match?.[1] !== undefined && match[2] !== undefined && match[2].trim() !== '') {
355
+ this.#paramDocs.set(match[1], match[2].trim())
356
+ }
357
+ }
358
+ this.#strict = input.document.strict
359
+ }
360
+
361
+ build(): OperationModel {
362
+ const { route, httpMethod, controller } = this.#source
363
+ const method = httpMethod.toLowerCase()
364
+ const options = this.#apiOperation()
365
+ const operation: Json = {}
366
+ const operationId = this.#operationId(options)
367
+ operation['operationId'] = operationId
368
+ const summary = stringOption(options, 'summary') ?? this.#doc?.summary
369
+ const description = stringOption(options, 'description') ?? this.#doc?.description
370
+ if (summary !== undefined) operation['summary'] = summary
371
+ if (description !== undefined) operation['description'] = description
372
+ const methodTags = tagsOf(this.#ctx, this.#source.methodDecorators)
373
+ const tags = [...new Set([...this.#source.classTags, ...methodTags])]
374
+ operation['tags'] = tags.length > 0 ? tags : [this.#source.defaultTag]
375
+ const deprecated =
376
+ typeof options?.['deprecated'] === 'boolean'
377
+ ? options['deprecated']
378
+ : this.#doc?.tags.some(tag => tag.name === 'deprecated') === true
379
+ if (deprecated) operation['deprecated'] = true
380
+ if (isRecord(options?.['externalDocs'])) operation['externalDocs'] = toJson(options['externalDocs'])
381
+ const parameters = this.#parameters()
382
+ if (parameters.length > 0) operation['parameters'] = parameters
383
+ const body = this.#requestBody(httpMethod)
384
+ if (body !== undefined) operation['requestBody'] = body
385
+ operation['responses'] = this.#responses(httpMethod)
386
+ const security = this.#security()
387
+ if (security !== undefined) operation['security'] = security
388
+ if (isRecord(options?.['extensions'])) {
389
+ for (const [key, value] of Object.entries(options['extensions'])) {
390
+ if (key.startsWith('x-')) operation[key] = toJson(value)
391
+ }
392
+ }
393
+ if (route.fullPath.endsWith('/*')) operation['x-znight-wildcard'] = true
394
+ return {
395
+ controller: controller.token,
396
+ handler: route.handler,
397
+ route: route.method,
398
+ method,
399
+ path: pathTemplate(route.fullPath),
400
+ operation,
401
+ operationId,
402
+ label: this.#label,
403
+ location: this.#location,
404
+ }
405
+ }
406
+
407
+ // ------------------------------------------------------------------------------------------------
408
+ // identity and texts
409
+
410
+ #apiOperation(): Readonly<Record<string, StaticValue>> | undefined {
411
+ const decorator = this.#source.methodDecorators.find(candidate => candidate.identity === Ids.ApiOperation)
412
+ const arg = decorator?.args[0]
413
+ if (arg === undefined) return undefined
414
+ if (!isRecord(arg.value)) {
415
+ invalidValue(
416
+ this.#ctx,
417
+ arg,
418
+ `@ApiOperation takes { summary?, description?, operationId?, deprecated?, externalDocs?, extensions? }, received ${describe(arg.value)}`,
419
+ )
420
+ return undefined
421
+ }
422
+ const reserved = findReservedKey(arg.value)
423
+ if (reserved !== undefined) invalidValue(this.#ctx, arg, `"$znight" is a reserved key (${reserved})`)
424
+ for (const key of Object.keys(arg.value)) {
425
+ if (!['summary', 'description', 'operationId', 'deprecated', 'externalDocs', 'extensions'].includes(key)) {
426
+ invalidValue(this.#ctx, arg, `@ApiOperation: unknown option "${key}"`)
427
+ }
428
+ }
429
+ const extensions = arg.value['extensions']
430
+ if (isRecord(extensions)) {
431
+ for (const key of Object.keys(extensions)) {
432
+ if (!key.startsWith('x-'))
433
+ invalidValue(this.#ctx, arg, `@ApiOperation: the extension "${key}" must start with "x-"`)
434
+ }
435
+ }
436
+ return arg.value
437
+ }
438
+
439
+ #operationId(options: Readonly<Record<string, StaticValue>> | undefined): string {
440
+ const { controller, route, httpMethod } = this.#source
441
+ const explicit = options?.['operationId']
442
+ if (explicit !== undefined) {
443
+ if (typeof explicit !== 'string' || !OPERATION_ID.test(explicit)) {
444
+ const decorator = this.#source.methodDecorators.find(candidate => candidate.identity === Ids.ApiOperation)
445
+ invalidValue(
446
+ this.#ctx,
447
+ decorator ?? { location: this.#location },
448
+ `Invalid operation id ${describe(explicit)}: it must match ${String(OPERATION_ID)}`,
449
+ )
450
+ } else {
451
+ return route.method === 'ALL' ? `${explicit}.${httpMethod.toLowerCase()}` : explicit
452
+ }
453
+ }
454
+ const base = controller.displayName.replace(/Controller$/, '') || controller.displayName
455
+ let id: string
456
+ switch (this.#input.document.operationIds) {
457
+ case 'handler':
458
+ id = route.handler
459
+ break
460
+ case 'Controller_handler':
461
+ id = `${controller.displayName}_${route.handler}`
462
+ break
463
+ default:
464
+ id = `${base.charAt(0).toLowerCase()}${base.slice(1)}.${route.handler}`
465
+ }
466
+ if (route.method === 'ALL') id = `${id}.${httpMethod.toLowerCase()}`
467
+ if (!OPERATION_ID.test(id)) id = id.replace(/[^\w.-]/g, '_').replace(/^[^A-Za-z_]/, '_')
468
+ return id
469
+ }
470
+
471
+ #use(part: string, io: 'input' | 'output', location?: Location): SchemaUse {
472
+ return { io, operation: this.#label, part, location: location ?? this.#location }
473
+ }
474
+
475
+ #paramInfo(data: HttpParamData): ParameterInfo | undefined {
476
+ return this.#source.method?.params.find(param => param.index === data.index)
477
+ }
478
+
479
+ #paramDoc(data: HttpParamData): string | undefined {
480
+ return this.#paramDocs.get(this.#paramInfo(data)?.name ?? data.name)
481
+ }
482
+
483
+ // ------------------------------------------------------------------------------------------------
484
+ // parameters
485
+
486
+ #parameters(): unknown[] {
487
+ const { route } = this.#source
488
+ const parameters: unknown[] = []
489
+ const declared = new Set<string>()
490
+ const params = route.params
491
+ // path parameters, in path order
492
+ const wholePath = params.find(
493
+ param => param.source === 'param' && param.key === undefined && param.schema !== undefined,
494
+ )
495
+ if (wholePath?.schema === undefined) {
496
+ for (const segment of route.fullPath.split('/')) {
497
+ if (!segment.startsWith(':') && segment !== '*') continue
498
+ const key = segment === '*' ? '*' : segment.slice(1)
499
+ const data = params.find(param => param.source === 'param' && param.key === key)
500
+ const parameter: Json = { name: key === '*' ? wildcardName(route.fullPath) : key, in: 'path' }
501
+ const doc = data === undefined ? undefined : this.#paramDoc(data)
502
+ if (doc !== undefined) parameter['description'] = doc
503
+ else if (key === '*') parameter['description'] = 'The rest of the path, `/` included'
504
+ parameter['required'] = true
505
+ parameter['schema'] =
506
+ data === undefined ? { type: 'string' } : this.#paramSchema(data, `path parameter "${key}"`)
507
+ if (key === '*') parameter['x-znight-wildcard'] = true
508
+ parameters.push(parameter)
509
+ declared.add(`path ${parameter['name']}`)
510
+ }
511
+ } else {
512
+ const name = this.#input.slots.use(wholePath.schema, 'input')?.name
513
+ if (name !== undefined) parameters.push(placeholder('path', name))
514
+ }
515
+ for (const data of params) {
516
+ if (data.source === 'query') this.#signatureParameter(data, 'query', parameters, declared)
517
+ else if (data.source === 'headers') this.#signatureParameter(data, 'header', parameters, declared)
518
+ else if (data.source === 'cookie' || data.source === 'signedCookie') {
519
+ this.#signatureParameter(data, 'cookie', parameters, declared)
520
+ }
521
+ }
522
+ const decorated = [
523
+ ...this.#source.classDecorators.filter(decorator => decorator.identity === Ids.ApiHeader),
524
+ ...this.#source.methodDecorators.filter(
525
+ decorator => decorator.identity === Ids.ApiQuery || decorator.identity === Ids.ApiHeader,
526
+ ),
527
+ ]
528
+ for (const decorator of decorated) {
529
+ const parameter = this.#decoratedParameter(decorator, declared)
530
+ if (parameter !== undefined) parameters.push(parameter)
531
+ }
532
+ return parameters
533
+ }
534
+
535
+ #signatureParameter(
536
+ data: HttpParamData,
537
+ location: 'query' | 'header' | 'cookie',
538
+ parameters: unknown[],
539
+ declared: Set<string>,
540
+ ): void {
541
+ if (data.key === undefined) {
542
+ if (data.schema !== undefined) {
543
+ const name = this.#input.slots.use(data.schema, 'input')?.name
544
+ if (name !== undefined) parameters.push(placeholder(location, name))
545
+ return
546
+ }
547
+ // `@Query() q: SearchParams`: one parameter per property of the type
548
+ const info = this.#paramInfo(data)
549
+ if (info === undefined || location !== 'query') return
550
+ const result = this.#input.schemas.shape({ parameter: info })
551
+ const use = this.#use(`the ${location} parameters`, 'input', info.typeLocation)
552
+ this.#input.schemas.register(result, use)
553
+ const object = this.#objectProperties(result.root)
554
+ for (const property of object ?? []) {
555
+ const parameter: Json = { name: property.name, in: location }
556
+ if (property.description !== undefined) parameter['description'] = property.description
557
+ if (!property.optional) parameter['required'] = true
558
+ parameter['schema'] = this.#input.schemas.convert(property.shape, use, property.name)
559
+ if (property.shape.kind === 'array') {
560
+ parameter['style'] = 'form'
561
+ parameter['explode'] = true
562
+ }
563
+ parameters.push(parameter)
564
+ declared.add(`${location} ${property.name}`)
565
+ }
566
+ return
567
+ }
568
+ if (location === 'header' && IGNORED_HEADERS.has(data.key)) {
569
+ this.#ctx.report({
570
+ code: Codes.InconsistentOperation,
571
+ severity: 'warning',
572
+ message: `The "${data.key}" header of ${this.#label} is not documented as a parameter: OpenAPI ignores Accept, Content-Type and Authorization parameters`,
573
+ location: data.location,
574
+ hints: ['Describe credentials with a security scheme (securityScheme()) and @ApiSecurity or decisions'],
575
+ })
576
+ return
577
+ }
578
+ const parameter: Json = { name: data.key, in: location }
579
+ const doc = this.#paramDoc(data)
580
+ if (doc !== undefined) parameter['description'] = doc
581
+ if (!data.optional) parameter['required'] = true
582
+ parameter['schema'] = this.#paramSchema(data, `${location} parameter "${data.key}"`)
583
+ if (location === 'query' && isArrayPlan(data.coercion?.plan)) {
584
+ parameter['style'] = 'form'
585
+ parameter['explode'] = true
586
+ }
587
+ parameters.push(parameter)
588
+ declared.add(`${location} ${data.key}`)
589
+ }
590
+
591
+ #objectProperties(
592
+ shape: Shape,
593
+ ): { name: string; shape: Shape; optional: boolean; description: string | undefined }[] | undefined {
594
+ if (shape.kind === 'named') {
595
+ const named = this.#input.schemas.named(shape.key)
596
+ return named === undefined ? undefined : this.#objectProperties(named.shape)
597
+ }
598
+ if (shape.kind !== 'object') return undefined
599
+ return shape.properties.map(property => ({
600
+ name: property.name,
601
+ shape: property.shape,
602
+ optional: property.optional,
603
+ description: property.doc?.text === '' ? undefined : property.doc?.text,
604
+ }))
605
+ }
606
+
607
+ #paramSchema(data: HttpParamData, part: string): SchemaTemplate {
608
+ if (data.schema !== undefined) return this.#input.slots.use(data.schema, 'input') ?? {}
609
+ const schema: Record<string, unknown> = data.coercion === undefined ? {} : { ...planSchema(data.coercion.plan) }
610
+ if (data.defaultValue !== undefined) {
611
+ const value = toJson(data.defaultValue)
612
+ if (value !== undefined) schema['default'] = value
613
+ }
614
+ void part
615
+ return schema as JsonSchema
616
+ }
617
+
618
+ #decoratedParameter(decorator: DecoratorInfo, declared: Set<string>): Json | undefined {
619
+ const location = decorator.identity === Ids.ApiQuery ? 'query' : 'header'
620
+ const [nameArg, optionsArg] = decorator.args
621
+ const name = nameArg?.value
622
+ if (typeof name !== 'string' || name === '') {
623
+ invalidValue(this.#ctx, nameArg ?? decorator, `@${decorator.name} needs a parameter name`)
624
+ return undefined
625
+ }
626
+ const key = location === 'header' ? name.toLowerCase() : name
627
+ if (location === 'header' && !HEADER_NAME.test(name)) {
628
+ invalidValue(this.#ctx, nameArg ?? decorator, `Invalid header name ${JSON.stringify(name)}`)
629
+ return undefined
630
+ }
631
+ if (location === 'header' && IGNORED_HEADERS.has(key)) {
632
+ this.#ctx.report({
633
+ code: Codes.InconsistentOperation,
634
+ severity: 'error',
635
+ message: `@ApiHeader('${name}'): OpenAPI ignores Accept, Content-Type and Authorization parameters`,
636
+ location: decorator.location,
637
+ hints: ['Describe credentials with a security scheme: securityScheme() and @ApiSecurity'],
638
+ })
639
+ return undefined
640
+ }
641
+ if (declared.has(`${location} ${key}`)) {
642
+ this.#ctx.report({
643
+ code: Codes.InconsistentOperation,
644
+ severity: 'error',
645
+ message: `@${decorator.name}('${name}') documents the ${location} parameter "${key}" of ${this.#label}, which the signature already reads`,
646
+ location: decorator.location,
647
+ hints: [`Remove @${decorator.name}: the parameter is documented from the signature`],
648
+ })
649
+ return undefined
650
+ }
651
+ declared.add(`${location} ${key}`)
652
+ const options = optionsArg?.value
653
+ if (options !== undefined && !isRecord(options)) {
654
+ invalidValue(
655
+ this.#ctx,
656
+ optionsArg ?? decorator,
657
+ `@${decorator.name} options must be { description?, required?, deprecated?, example? }`,
658
+ )
659
+ }
660
+ const record = isRecord(options) ? options : {}
661
+ for (const option of Object.keys(record)) {
662
+ if (!['description', 'required', 'deprecated', 'example'].includes(option)) {
663
+ invalidValue(this.#ctx, optionsArg ?? decorator, `@${decorator.name}: unknown option "${option}"`)
664
+ }
665
+ }
666
+ const parameter: Json = { name: key, in: location }
667
+ if (typeof record['description'] === 'string') parameter['description'] = record['description']
668
+ if (record['required'] === true) parameter['required'] = true
669
+ if (record['deprecated'] === true) parameter['deprecated'] = true
670
+ if (decorator.typeArgs.length > 0) {
671
+ const result = this.#input.schemas.shape({ decorator, typeArgument: 0 })
672
+ parameter['schema'] = this.#input.schemas.fromResult(
673
+ result,
674
+ this.#use(`the ${location} parameter "${key}"`, 'input', decorator.location),
675
+ )
676
+ } else {
677
+ parameter['schema'] = { type: 'string' }
678
+ }
679
+ if (record['example'] !== undefined) parameter['example'] = toJson(record['example'])
680
+ return parameter
681
+ }
682
+
683
+ // ------------------------------------------------------------------------------------------------
684
+ // request body
685
+
686
+ #requestBody(httpMethod: string): Json | undefined {
687
+ const { route } = this.#source
688
+ const body = route.params.find(param => param.source === 'body')
689
+ const uploads = route.params.filter(param => param.upload !== undefined)
690
+ const apiBody = this.#source.methodDecorators.find(decorator => decorator.identity === Ids.ApiBody)
691
+ if (apiBody !== undefined && (httpMethod === 'GET' || httpMethod === 'HEAD')) {
692
+ this.#ctx.report({
693
+ code: Codes.InconsistentOperation,
694
+ severity: 'warning',
695
+ message: `@ApiBody on ${this.#label}: ${httpMethod} requests have no body in OpenAPI tools`,
696
+ location: apiBody.location,
697
+ hints: ['Use another method, or remove @ApiBody'],
698
+ })
699
+ }
700
+ if (apiBody !== undefined && (body?.schema !== undefined || route.bodySchema !== undefined)) {
701
+ this.#ctx.report({
702
+ code: Codes.InconsistentOperation,
703
+ severity: 'error',
704
+ message: `@ApiBody on ${this.#label}, whose body is already documented by its schema`,
705
+ location: apiBody.location,
706
+ hints: ['Remove @ApiBody: the body schema documents the request'],
707
+ })
708
+ return this.#bodyFromSignature(body, uploads)
709
+ }
710
+ if (apiBody !== undefined && uploads.length === 0) return this.#declaredBody(apiBody, body)
711
+ return this.#bodyFromSignature(body, uploads)
712
+ }
713
+
714
+ #bodyFromSignature(body: HttpParamData | undefined, uploads: readonly HttpParamData[]): Json | undefined {
715
+ const { route } = this.#source
716
+ if (uploads.length > 0) return this.#multipart(body, uploads)
717
+ if (body !== undefined) {
718
+ const request: Json = {}
719
+ const doc = this.#paramDoc(body)
720
+ if (doc !== undefined) request['description'] = doc
721
+ let content: Json
722
+ if (body.schema === undefined) {
723
+ const info = this.#paramInfo(body)
724
+ const result = info === undefined ? undefined : this.#input.schemas.shape({ parameter: info })
725
+ const root = result?.root
726
+ if (result === undefined || root === undefined) content = { 'application/json': { schema: {} } }
727
+ else if (root.kind === 'string') content = { 'text/plain': { schema: { type: 'string' } } }
728
+ else if (root.kind === 'binary') content = { 'application/octet-stream': {} }
729
+ else {
730
+ content = {
731
+ 'application/json': {
732
+ schema: this.#input.schemas.fromResult(
733
+ result,
734
+ this.#use('the request body', 'input', info?.typeLocation),
735
+ ),
736
+ },
737
+ }
738
+ }
739
+ } else {
740
+ content = { 'application/json': { schema: this.#input.slots.use(body.schema, 'input') ?? {} } }
741
+ }
742
+ request['content'] = content
743
+ if (!body.optional) request['required'] = true
744
+ return request
745
+ }
746
+ if (route.bodySchema !== undefined) {
747
+ return {
748
+ content: { 'application/json': { schema: this.#input.slots.use(route.bodySchema, 'input') ?? {} } },
749
+ required: true,
750
+ }
751
+ }
752
+ return undefined
753
+ }
754
+
755
+ #multipart(form: HttpParamData | undefined, uploads: readonly HttpParamData[]): Json {
756
+ const properties: Json = {}
757
+ const required: string[] = []
758
+ const encoding: Json = {}
759
+ let additional: Json | undefined
760
+ for (const param of uploads) {
761
+ const upload = param.upload as UploadData
762
+ const file: Json = { type: 'string', contentMediaType: 'application/octet-stream' }
763
+ const doc = this.#paramDoc(param)
764
+ const size =
765
+ upload.maxSize === undefined || upload.maxSize === Number.POSITIVE_INFINITY ? undefined : upload.maxSize
766
+ const descriptions = [doc, size === undefined ? undefined : `At most ${formatSize(size)}`].filter(
767
+ text => text !== undefined,
768
+ )
769
+ if (descriptions.length > 0) file['description'] = descriptions.join('. ')
770
+ if (size !== undefined) file['x-znight-max-size'] = size
771
+ const schema: Json = upload.multiple
772
+ ? {
773
+ type: 'array',
774
+ items: file,
775
+ ...(upload.maxFiles === undefined || upload.maxFiles === Number.POSITIVE_INFINITY
776
+ ? {}
777
+ : { maxItems: upload.maxFiles }),
778
+ }
779
+ : file
780
+ if (upload.field === undefined) {
781
+ additional = schema
782
+ continue
783
+ }
784
+ properties[upload.field] = schema
785
+ if (upload.required) required.push(upload.field)
786
+ if (upload.mimeTypes !== undefined && upload.mimeTypes.length > 0) {
787
+ encoding[upload.field] = { contentType: upload.mimeTypes.join(', ') }
788
+ }
789
+ }
790
+ const files: Json = { type: 'object' }
791
+ if (Object.keys(properties).length > 0) files['properties'] = properties
792
+ if (required.length > 0) files['required'] = required.sort(compareStrings)
793
+ if (additional !== undefined) files['additionalProperties'] = additional
794
+ const textFields = form?.schema === undefined ? undefined : this.#input.slots.use(form.schema, 'input')
795
+ const media: Json = { schema: textFields === undefined ? files : { allOf: [textFields, files] } }
796
+ if (Object.keys(encoding).length > 0) media['encoding'] = encoding
797
+ const request: Json = { content: { 'multipart/form-data': media } }
798
+ if (required.length > 0 || (form !== undefined && !form.optional)) request['required'] = true
799
+ return request
800
+ }
801
+
802
+ #declaredBody(decorator: DecoratorInfo, body: HttpParamData | undefined): Json | undefined {
803
+ void body
804
+ const arg = decorator.args[0]
805
+ const options = arg?.value
806
+ if (options !== undefined && !isRecord(options)) {
807
+ invalidValue(
808
+ this.#ctx,
809
+ arg ?? decorator,
810
+ '@ApiBody options must be { description?, required?, contentType?, schema? }',
811
+ )
812
+ return undefined
813
+ }
814
+ const record = isRecord(options) ? options : {}
815
+ for (const key of Object.keys(record)) {
816
+ if (!['description', 'required', 'contentType', 'schema'].includes(key)) {
817
+ invalidValue(this.#ctx, arg ?? decorator, `@ApiBody: unknown option "${key}"`)
818
+ }
819
+ }
820
+ const contentTypes = this.#mediaTypes(record['contentType'], arg ?? decorator) ?? ['application/json']
821
+ let schema: SchemaTemplate | undefined
822
+ const schemaOption = record['schema']
823
+ if (isRef(schemaOption)) schema = this.#input.slots.use(schemaOption, 'input')
824
+ else if (schemaOption !== undefined)
825
+ invalidValue(this.#ctx, arg ?? decorator, '@ApiBody schema must be a Standard Schema (an exported schema)')
826
+ else if (decorator.typeArgs.length > 0) {
827
+ const result = this.#input.schemas.shape({ decorator, typeArgument: 0 })
828
+ schema = this.#input.schemas.fromResult(result, this.#use('the request body', 'input', decorator.location))
829
+ }
830
+ const content: Json = {}
831
+ for (const type of contentTypes) {
832
+ content[type] = schema === undefined ? defaultMedia(type) : { schema }
833
+ }
834
+ const request: Json = {}
835
+ if (typeof record['description'] === 'string') request['description'] = record['description']
836
+ request['content'] = content
837
+ if (record['required'] !== false) request['required'] = true
838
+ return request
839
+ }
840
+
841
+ #mediaTypes(value: StaticValue, where: ArgInfo | DecoratorInfo): string[] | undefined {
842
+ if (value === undefined) return undefined
843
+ let list: readonly StaticValue[] | undefined
844
+ if (typeof value === 'string') list = [value]
845
+ else if (Array.isArray(value)) list = value as readonly StaticValue[]
846
+ if (
847
+ list === undefined ||
848
+ list.length === 0 ||
849
+ list.some(item => typeof item !== 'string' || !MEDIA_TYPE.test(item))
850
+ ) {
851
+ invalidValue(this.#ctx, where, `Invalid media type ${describe(value)}: expected "type/subtype" or a list of them`)
852
+ return undefined
853
+ }
854
+ return list as string[]
855
+ }
856
+
857
+ // ------------------------------------------------------------------------------------------------
858
+ // responses
859
+
860
+ #responses(httpMethod: string): Json {
861
+ const { route, method } = this.#source
862
+ const errors = this.#input.document.errors
863
+ const declared = this.#source.methodDecorators.filter(decorator => decorator.identity === Ids.ApiResponse)
864
+ const declaredStatuses = declared
865
+ .map(decorator => decorator.args[0]?.value)
866
+ .map(status => (typeof status === 'number' ? String(status) : status))
867
+ const success = this.#success(httpMethod)
868
+ const responses = new Map<string, ResponseDraft>()
869
+ const covered =
870
+ declaredStatuses.includes(String(success.status)) ||
871
+ declaredStatuses.includes('2XX') ||
872
+ (success.dynamic && declaredStatuses.some(status => typeof status === 'string' && /^2\d\d$/.test(status)))
873
+ if (success.problem !== undefined && !covered) {
874
+ this.#ctx.report({
875
+ code: Codes.UndocumentedResponse,
876
+ severity: this.#strict ? 'error' : 'warning',
877
+ message: `The ${success.status} response of ${this.#label} cannot be documented: ${success.problem}`,
878
+ location: success.location ?? this.#location,
879
+ hints: success.hints,
880
+ })
881
+ }
882
+ const returns = this.#doc?.tags.find(tag => tag.name === 'returns' || tag.name === 'return')?.text
883
+ if (success.problem === undefined || !covered) {
884
+ const headers: Record<string, Json> = {}
885
+ for (const [name, value] of route.headers) {
886
+ headers[name] = { schema: { type: 'string' }, example: value }
887
+ }
888
+ responses.set(String(success.status), {
889
+ description: returns !== undefined && returns !== '' ? returns : undefined,
890
+ headers,
891
+ content: success.content,
892
+ })
893
+ if (success.empty !== undefined) {
894
+ responses.set(String(success.empty), { description: undefined, headers: { ...headers }, content: undefined })
895
+ }
896
+ }
897
+ for (const decorator of declared) this.#declaredResponse(decorator, responses, success)
898
+ // standard errors
899
+ const result: Json = {}
900
+ const reads =
901
+ route.params.some(param => param.source === 'body' || param.upload !== undefined) ||
902
+ route.bodySchema !== undefined
903
+ const validates =
904
+ reads ||
905
+ route.params.some(
906
+ param => param.schema !== undefined || (param.coercion !== undefined && param.coercion.plan.kind !== 'raw'),
907
+ )
908
+ const uploads = route.params.some(param => param.upload !== undefined)
909
+ if (errors !== false) {
910
+ const standard = (status: string, name: string): void => {
911
+ if (!responses.has(status)) result[status] = { $ref: `#/components/responses/${name}` }
912
+ }
913
+ if (validates) standard('400', ERROR_RESPONSES.badRequest)
914
+ if (reads) standard('413', ERROR_RESPONSES.payloadTooLarge)
915
+ if (uploads) standard('415', ERROR_RESPONSES.unsupportedMediaType)
916
+ }
917
+ for (const [status, draft] of responses) {
918
+ const response: Json = { description: draft.description ?? reasonPhrase(status) }
919
+ if (Object.keys(draft.headers).length > 0) response['headers'] = draft.headers
920
+ let content = draft.content
921
+ if (content === undefined && errors !== false && /^[45]/.test(status)) {
922
+ const schema =
923
+ errors.schema === undefined
924
+ ? { $ref: schemaRef(ERROR_COMPONENTS.body) }
925
+ : (this.#input.slots.use(errors.schema, 'output') ?? {})
926
+ content = { [errors.contentType]: { schema } }
927
+ }
928
+ if (content !== undefined) response['content'] = content
929
+ result[status] = response
930
+ }
931
+ void method
932
+ const sorted: Json = {}
933
+ for (const status of Object.keys(result).sort(compareStatuses)) sorted[status] = result[status]
934
+ return sorted
935
+ }
936
+
937
+ #success(httpMethod: string): SuccessModel {
938
+ const { route, method } = this.#source
939
+ const example = exampleBodyType(this.#source.controller.displayName)
940
+ const post = httpMethod === 'POST'
941
+ const statusFor = (empty: boolean): number => {
942
+ if (route.status !== undefined) return route.status
943
+ if (post) return 201
944
+ return empty ? 204 : 200
945
+ }
946
+ const undocumentable = (problem: string, hints: string[], dynamic = false): SuccessModel => ({
947
+ status: statusFor(false),
948
+ dynamic,
949
+ empty: undefined,
950
+ content: undefined,
951
+ problem,
952
+ hints,
953
+ location: method?.location,
954
+ })
955
+ if (route.sse !== undefined) return this.#sse()
956
+ if (route.rawResponse) {
957
+ return undocumentable(
958
+ 'the handler writes the response itself (@Res())',
959
+ [`Declare it: @ApiResponse<${example}>(${statusFor(false)})`],
960
+ true,
961
+ )
962
+ }
963
+ if (method?.returnType === undefined) {
964
+ return undocumentable('the handler has no return type annotation', [
965
+ `Annotate the return type with the body type: ${handlerKey(route.handler)}(): ${method?.async === true ? `Promise<${example}>` : example}`,
966
+ `Or declare it: @ApiResponse<${example}>(${statusFor(false)})`,
967
+ ])
968
+ }
969
+ const result = this.#input.schemas.shape({ returnOf: method })
970
+ const use = this.#use('the response', 'output')
971
+ this.#input.schemas.register(result, use)
972
+ // `HttpResponse<T>` members are documented as `T`, their status is chosen at runtime
973
+ let dynamic = false
974
+ let returnsUndefined = false
975
+ const bodies: Shape[] = []
976
+ for (const member of unionMembers(result.root)) {
977
+ if (member.kind === 'reference' && member.identity === HttpIds.HttpResponse) {
978
+ dynamic = true
979
+ const body = member.typeArgs[0]
980
+ if (member.explicitTypeArgs === 0 || body === undefined) {
981
+ return undocumentable(
982
+ 'HttpResponse has no body type',
983
+ [
984
+ `Give the body type: Promise<HttpResponse<${example}>>`,
985
+ `Or declare it: @ApiResponse<${example}>(${statusFor(false)})`,
986
+ ],
987
+ true,
988
+ )
989
+ }
990
+ bodies.push(...unionMembers(body))
991
+ } else {
992
+ if (member.kind === 'undefined') returnsUndefined = true
993
+ bodies.push(member)
994
+ }
995
+ }
996
+ const defined = distinct(bodies.filter(member => member.kind !== 'undefined' && member.kind !== 'never'))
997
+ if (defined.some(member => member.kind === 'reference' && member.identity === 'globalThis#Response')) {
998
+ return undocumentable(
999
+ 'a Web Response has no body type',
1000
+ [`Declare it: @ApiResponse<${example}>(${statusFor(false)})`],
1001
+ true,
1002
+ )
1003
+ }
1004
+ if (defined.some(member => member.kind === 'unknown')) {
1005
+ return undocumentable(
1006
+ `the return type is ${method.returnType.kind === 'primitive' ? method.returnType.name : 'unknown'}`,
1007
+ ['Give a precise return type', `Or declare it: @ApiResponse<${example}>(${statusFor(false)})`],
1008
+ dynamic,
1009
+ )
1010
+ }
1011
+ if (defined.length === 0) {
1012
+ return {
1013
+ status: statusFor(true),
1014
+ dynamic,
1015
+ empty: undefined,
1016
+ content: undefined,
1017
+ problem: undefined,
1018
+ hints: [],
1019
+ location: undefined,
1020
+ }
1021
+ }
1022
+ const events = defined.filter(member => member.kind === 'reference' && member.identity === HttpIds.SseBody)
1023
+ if (events.length > 0) {
1024
+ // HttpResponse.sse(): 200 (even on POST) unless @HttpCode, text/event-stream
1025
+ if (events.length < defined.length) {
1026
+ return undocumentable(
1027
+ 'the handler returns an event stream or another body',
1028
+ [`Declare the responses: @ApiResponse(${route.status ?? 200}, { contentType: 'text/event-stream' })`],
1029
+ true,
1030
+ )
1031
+ }
1032
+ return {
1033
+ status: route.status ?? 200,
1034
+ dynamic: true,
1035
+ empty: undefined,
1036
+ content: httpMethod === 'HEAD' ? undefined : { 'text/event-stream': { schema: { type: 'string' } } },
1037
+ problem: undefined,
1038
+ hints: [],
1039
+ location: undefined,
1040
+ }
1041
+ }
1042
+ const status = statusFor(false)
1043
+ let content: Record<string, Json> | undefined = this.#successContent(defined, use)
1044
+ if (content !== undefined && isBodylessStatus(status)) {
1045
+ this.#ctx.report({
1046
+ code: Codes.InconsistentOperation,
1047
+ severity: 'error',
1048
+ message: `${this.#label} returns a body, but its status is always ${status} (@HttpCode(${status})): ${status} responses have none, the body is never sent`,
1049
+ location: method.location,
1050
+ hints: ['Return nothing (void), or use another status'],
1051
+ })
1052
+ content = undefined
1053
+ }
1054
+ return {
1055
+ status,
1056
+ dynamic,
1057
+ // a plain `undefined` is sent as an empty 204 (not on POST routes, not with @HttpCode)
1058
+ empty: returnsUndefined && route.status === undefined && !post && status !== 204 ? 204 : undefined,
1059
+ // HEAD responses never have a body
1060
+ content: httpMethod === 'HEAD' ? undefined : content,
1061
+ problem: undefined,
1062
+ hints: [],
1063
+ location: undefined,
1064
+ }
1065
+ }
1066
+
1067
+ /** Content of the success response: media types of the serialiser (§9.1 of the architecture). */
1068
+ #successContent(members: readonly Shape[], use: SchemaUse): Record<string, Json> | undefined {
1069
+ const single = members.length === 1 ? (members[0] as Shape) : undefined
1070
+ if (single !== undefined) {
1071
+ if (single.kind === 'binary' || (single.kind === 'reference' && single.identity === HttpIds.FileBody)) {
1072
+ return { 'application/octet-stream': {} }
1073
+ }
1074
+ if (single.kind === 'reference' && single.identity === 'globalThis#AsyncIterable') {
1075
+ const event = single.typeArgs[0]
1076
+ const data = event?.kind === 'reference' && event.identity === HttpIds.SseEvent ? event.typeArgs[0] : event
1077
+ const media: Json = { schema: { type: 'string' } }
1078
+ if (data !== undefined) media['x-znight-event-schema'] = this.#input.schemas.convert(data, use, '')
1079
+ return { 'text/event-stream': media }
1080
+ }
1081
+ }
1082
+ // strings are sent as text/plain, everything else as JSON
1083
+ const text: Shape[] = []
1084
+ const json: Shape[] = []
1085
+ for (const member of members) this.#splitText(member, text, json, new Set())
1086
+ const schemaOf = (shapes: Shape[]): SchemaTemplate =>
1087
+ this.#input.schemas.convert(
1088
+ shapes.length === 1 ? (shapes[0] as Shape) : { kind: 'union', members: shapes },
1089
+ use,
1090
+ '',
1091
+ )
1092
+ const content: Record<string, Json> = {}
1093
+ if (json.length > 0) content['application/json'] = { schema: schemaOf(json) }
1094
+ if (text.length > 0) content['text/plain'] = { schema: schemaOf(text) }
1095
+ return content
1096
+ }
1097
+
1098
+ /**
1099
+ * Sorts the members of a body by the media type the serialiser gives them: strings (string, string
1100
+ * literals, string enums) are text, the rest JSON. A named declaration stays whole when all its
1101
+ * members go to one side.
1102
+ */
1103
+ #splitText(shape: Shape, text: Shape[], json: Shape[], seen: Set<string>): void {
1104
+ const kind = this.#textKind(shape, new Set(seen))
1105
+ if (kind === 'text') text.push(shape)
1106
+ else if (kind === 'json') json.push(shape)
1107
+ else if (shape.kind === 'union') {
1108
+ for (const member of shape.members) this.#splitText(member, text, json, seen)
1109
+ } else if (shape.kind === 'named') {
1110
+ const named = this.#input.schemas.named(shape.key)
1111
+ if (named === undefined || seen.has(shape.key)) json.push(shape)
1112
+ else this.#splitText(named.shape, text, json, new Set([...seen, shape.key]))
1113
+ } else {
1114
+ json.push(shape)
1115
+ }
1116
+ }
1117
+
1118
+ #textKind(shape: Shape, seen: Set<string>): 'text' | 'json' | 'mixed' {
1119
+ switch (shape.kind) {
1120
+ case 'string':
1121
+ return 'text'
1122
+ case 'literal':
1123
+ return typeof shape.value === 'string' ? 'text' : 'json'
1124
+ case 'union': {
1125
+ const kinds = new Set(shape.members.map(member => this.#textKind(member, seen)))
1126
+ return kinds.size === 1 ? ([...kinds][0] as 'text' | 'json' | 'mixed') : 'mixed'
1127
+ }
1128
+ case 'intersection':
1129
+ // branded strings (`string & { readonly brand: unique symbol }`)
1130
+ return shape.members.some(member => this.#textKind(member, seen) === 'text') ? 'text' : 'json'
1131
+ case 'named': {
1132
+ const named = this.#input.schemas.named(shape.key)
1133
+ if (named === undefined || seen.has(shape.key)) return 'json'
1134
+ seen.add(shape.key)
1135
+ return this.#textKind(named.shape, seen)
1136
+ }
1137
+ default:
1138
+ return 'json'
1139
+ }
1140
+ }
1141
+
1142
+ /** `@Sse` routes: a 200 event stream; the type of the events' `data` when the return type tells it. */
1143
+ #sse(): SuccessModel {
1144
+ const { method } = this.#source
1145
+ const media: Json = { schema: { type: 'string' } }
1146
+ if (method?.returnType !== undefined) {
1147
+ const result = this.#input.schemas.shape({ returnOf: method })
1148
+ const use = this.#use('the events', 'output')
1149
+ this.#input.schemas.register(result, use)
1150
+ const data = eventData(result.root)
1151
+ if (data !== undefined) media['x-znight-event-schema'] = this.#input.schemas.convert(data, use, '')
1152
+ }
1153
+ return {
1154
+ status: 200,
1155
+ dynamic: true,
1156
+ empty: undefined,
1157
+ content: { 'text/event-stream': media },
1158
+ problem: undefined,
1159
+ hints: [],
1160
+ location: undefined,
1161
+ }
1162
+ }
1163
+
1164
+ #declaredResponse(decorator: DecoratorInfo, responses: Map<string, ResponseDraft>, success: SuccessModel): void {
1165
+ const [statusArg, optionsArg] = decorator.args
1166
+ const raw = statusArg?.value
1167
+ const status = typeof raw === 'number' ? String(raw) : raw
1168
+ if (typeof status !== 'string' || !isResponseStatus(status)) {
1169
+ invalidValue(
1170
+ this.#ctx,
1171
+ statusArg ?? decorator,
1172
+ `Invalid response status ${describe(raw)}: expected a code (100-599), "2XX"-like or "default"`,
1173
+ )
1174
+ return
1175
+ }
1176
+ const options = optionsArg?.value
1177
+ if (options !== undefined && !isRecord(options)) {
1178
+ invalidValue(
1179
+ this.#ctx,
1180
+ optionsArg ?? decorator,
1181
+ '@ApiResponse options must be { description?, schema?, contentType?, headers?, example? }',
1182
+ )
1183
+ return
1184
+ }
1185
+ const record = isRecord(options) ? options : {}
1186
+ for (const key of Object.keys(record)) {
1187
+ if (!['description', 'schema', 'contentType', 'headers', 'example'].includes(key)) {
1188
+ invalidValue(this.#ctx, optionsArg ?? decorator, `@ApiResponse: unknown option "${key}"`)
1189
+ }
1190
+ }
1191
+ const reserved = findReservedKey(record)
1192
+ if (reserved !== undefined)
1193
+ invalidValue(this.#ctx, optionsArg ?? decorator, `"$znight" is a reserved key (${reserved})`)
1194
+ const code = Number(status)
1195
+ if (Number.isInteger(code) && code >= 200 && code < 300 && code !== success.status && code !== success.empty) {
1196
+ const fixed = this.#source.route.status !== undefined || !success.dynamic
1197
+ if (fixed) {
1198
+ this.#ctx.report({
1199
+ code: Codes.InconsistentOperation,
1200
+ severity: 'error',
1201
+ message: `@ApiResponse(${status}) on ${this.#label}, whose success status is always ${success.status}`,
1202
+ location: decorator.location,
1203
+ hints: [
1204
+ this.#source.route.status === undefined
1205
+ ? `Return an HttpResponse to choose the status, or document ${success.status}`
1206
+ : `@HttpCode(${this.#source.route.status}) sets the status: document ${this.#source.route.status}`,
1207
+ ],
1208
+ })
1209
+ return
1210
+ }
1211
+ }
1212
+ let schema: SchemaTemplate | undefined
1213
+ const schemaOption = record['schema']
1214
+ if (isRef(schemaOption)) schema = this.#input.slots.use(schemaOption, 'output')
1215
+ else if (schemaOption !== undefined)
1216
+ invalidValue(
1217
+ this.#ctx,
1218
+ optionsArg ?? decorator,
1219
+ '@ApiResponse schema must be a Standard Schema (an exported schema)',
1220
+ )
1221
+ else if (decorator.typeArgs.length > 0) {
1222
+ const result = this.#input.schemas.shape({ decorator, typeArgument: 0 })
1223
+ schema = this.#input.schemas.fromResult(result, this.#use(`the ${status} response`, 'output', decorator.location))
1224
+ }
1225
+ if (schema !== undefined && (status === '204' || status === '304')) {
1226
+ this.#ctx.report({
1227
+ code: Codes.InconsistentOperation,
1228
+ severity: 'error',
1229
+ message: `@ApiResponse(${status}) on ${this.#label} declares a body: ${status} responses have none`,
1230
+ location: decorator.location,
1231
+ hints: ['Remove the body type, or use another status'],
1232
+ })
1233
+ schema = undefined
1234
+ }
1235
+ const contentTypes = this.#mediaTypes(record['contentType'], optionsArg ?? decorator)
1236
+ const draft = responses.get(status) ?? { description: undefined, headers: {}, content: undefined }
1237
+ if (typeof record['description'] === 'string') draft.description = record['description']
1238
+ const headers = record['headers']
1239
+ if (headers !== undefined) {
1240
+ if (isRecord(headers)) {
1241
+ for (const [name, header] of Object.entries(headers)) {
1242
+ if (!HEADER_NAME.test(name)) {
1243
+ invalidValue(this.#ctx, optionsArg ?? decorator, `Invalid header name ${JSON.stringify(name)}`)
1244
+ continue
1245
+ }
1246
+ const json = toJson(header)
1247
+ const object = isRecord(json) ? (json as Json) : {}
1248
+ draft.headers[name.toLowerCase()] = {
1249
+ ...draft.headers[name.toLowerCase()],
1250
+ ...object,
1251
+ schema: object['schema'] ?? { type: 'string' },
1252
+ }
1253
+ }
1254
+ } else
1255
+ invalidValue(
1256
+ this.#ctx,
1257
+ optionsArg ?? decorator,
1258
+ '@ApiResponse headers must be { [name]: { description?, schema?, ... } }',
1259
+ )
1260
+ }
1261
+ const example = record['example'] === undefined ? undefined : toJson(record['example'])
1262
+ if (schema !== undefined || contentTypes !== undefined) {
1263
+ const previous = draft.content === undefined ? undefined : Object.values(draft.content)[0]
1264
+ const types = contentTypes ?? (schema === undefined ? [] : ['application/json'])
1265
+ const content: Record<string, Json> = {}
1266
+ for (const type of types) {
1267
+ const media: Json = {}
1268
+ const mediaSchema = schema ?? (previous?.['schema'] as SchemaTemplate | undefined)
1269
+ if (mediaSchema !== undefined) media['schema'] = mediaSchema
1270
+ if (example !== undefined) media['example'] = example
1271
+ content[type] = media
1272
+ }
1273
+ draft.content = content
1274
+ } else if (example !== undefined && draft.content !== undefined) {
1275
+ for (const media of Object.values(draft.content)) media['example'] = example
1276
+ }
1277
+ responses.set(status, draft)
1278
+ }
1279
+
1280
+ // ------------------------------------------------------------------------------------------------
1281
+ // security
1282
+
1283
+ #security(): SecurityRequirementObject[] | undefined {
1284
+ const { schemes } = this.#input
1285
+ for (const decorators of [this.#source.methodDecorators, this.#source.classDecorators]) {
1286
+ const decorator = decorators.find(candidate => candidate.identity === Ids.ApiSecurity)
1287
+ if (decorator === undefined) continue
1288
+ const requirements: RequirementModel[] = []
1289
+ for (const arg of decorator.args) {
1290
+ const requirement = schemes.requirement(arg.ref ?? arg.value, arg.location)
1291
+ if (requirement !== undefined) requirements.push(requirement)
1292
+ }
1293
+ return schemes.objects(requirements)
1294
+ }
1295
+ const decisions = this.#input.document.decisions
1296
+ if (decisions.length === 0) return undefined
1297
+ const { route, controller } = this.#source
1298
+ const routeGroups = new Set(route.meta.map(entry => entry.group))
1299
+ const inherited = controller.meta.filter(entry => !routeGroups.has(entry.group))
1300
+ for (const entries of [route.meta, inherited]) {
1301
+ for (const decision of decisions) {
1302
+ const entry = entries.find(candidate => candidate.identity === decision.identity)
1303
+ if (entry === undefined) continue
1304
+ decision.used = true
1305
+ return this.#decisionSecurity(decision, entry)
1306
+ }
1307
+ }
1308
+ return undefined
1309
+ }
1310
+
1311
+ #decisionSecurity(decision: DecisionModel, entry: MetaEntry): SecurityRequirementObject[] | undefined {
1312
+ const { schemes } = this.#input
1313
+ if (!decision.scopesFromValue) return schemes.objects(decision.security)
1314
+ const value = entry.value
1315
+ if (!Array.isArray(value) || !(value as readonly StaticValue[]).every(item => typeof item === 'string')) {
1316
+ this.#ctx.report({
1317
+ code: Codes.InvalidSecurityRequirement,
1318
+ severity: 'error',
1319
+ message: `The decision on ${decision.text} takes its scopes from the decorator value, but @${entry.decorator}(...) on ${this.#label} is not a static string array`,
1320
+ location: entry.location,
1321
+ hints: [`Write the scopes literally: @${entry.decorator}(['admin'])`],
1322
+ })
1323
+ return schemes.objects(decision.security)
1324
+ }
1325
+ const scopes = value as readonly string[]
1326
+ for (const requirement of decision.security) {
1327
+ for (const { scheme } of requirement) {
1328
+ if (!scheme.scoped) {
1329
+ this.#ctx.report({
1330
+ code: Codes.InvalidSecurityRequirement,
1331
+ severity: 'error',
1332
+ message: `The decision on ${decision.text} gives scopes to "${scheme.name}" (${String(scheme.definition.type)}): scopes are only allowed on oauth2 and openIdConnect schemes`,
1333
+ location: decision.location,
1334
+ hints: ["Remove scopes: 'value', or use an oauth2 scheme"],
1335
+ })
1336
+ return schemes.objects(decision.security)
1337
+ }
1338
+ const unknown = scheme.scopes === undefined ? [] : scopes.filter(scope => !scheme.scopes?.has(scope))
1339
+ if (unknown.length > 0) {
1340
+ this.#ctx.report({
1341
+ code: Codes.InvalidSecurityRequirement,
1342
+ severity: 'error',
1343
+ message: `@${entry.decorator}(${JSON.stringify(scopes)}) on ${this.#label} uses ${unknown.length > 1 ? 'scopes' : 'a scope'} that "${scheme.name}" does not declare: ${unknown.join(', ')}`,
1344
+ location: entry.location,
1345
+ related: [{ message: 'the security scheme', location: scheme.location }],
1346
+ hints: [`Declare the scope in the flows of ${scheme.name}, or fix the decorator value`],
1347
+ })
1348
+ }
1349
+ }
1350
+ }
1351
+ return schemes.objects(decision.security, scopes)
1352
+ }
1353
+ }
1354
+
1355
+ /** A media type without declared schema: text is a string, bytes have no schema, anything else is any value. */
1356
+ function defaultMedia(type: string): Json {
1357
+ if (type.startsWith('text/')) return { schema: { type: 'string' } }
1358
+ return type === 'application/octet-stream' ? {} : { schema: {} }
1359
+ }
1360
+
1361
+ function stringOption(options: Readonly<Record<string, StaticValue>> | undefined, key: string): string | undefined {
1362
+ const value = options?.[key]
1363
+ return typeof value === 'string' ? value : undefined
1364
+ }
1365
+
1366
+ function placeholder(location: ParametersPlaceholder['in'], name: string): ParametersPlaceholder {
1367
+ return { $znight: 'parameters', in: location, name, io: 'input' }
1368
+ }
1369
+
1370
+ function isArrayPlan(plan: CoercionPlan | undefined): boolean {
1371
+ if (plan === undefined) return false
1372
+ if (plan.kind === 'optional') return isArrayPlan(plan.item)
1373
+ return plan.kind === 'array'
1374
+ }
1375
+
1376
+ /** The members of a union (the shape itself otherwise). */
1377
+ function unionMembers(shape: Shape): readonly Shape[] {
1378
+ return shape.kind === 'union' ? shape.members : [shape]
1379
+ }
1380
+
1381
+ /** The shapes without repetitions (`Book | HttpResponse<Book>` documents `Book` once). */
1382
+ function distinct(shapes: readonly Shape[]): Shape[] {
1383
+ const seen = new Set<string>()
1384
+ return shapes.filter(shape => {
1385
+ let key: string
1386
+ try {
1387
+ key = JSON.stringify(shape)
1388
+ } catch {
1389
+ return true
1390
+ }
1391
+ if (seen.has(key)) return false
1392
+ seen.add(key)
1393
+ return true
1394
+ })
1395
+ }
1396
+
1397
+ /** Statuses whose responses have no body: the serialiser drops it (204, 304, 1xx). */
1398
+ function isBodylessStatus(status: number): boolean {
1399
+ return status === 204 || status === 304 || (status >= 100 && status < 200)
1400
+ }
1401
+
1402
+ /** The `T` of `SseEvent<T>` found in a stream type (`AsyncIterable<SseEvent<T>> | HttpResponse`). */
1403
+ function eventData(shape: Shape): Shape | undefined {
1404
+ if (shape.kind === 'reference') {
1405
+ if (shape.identity === HttpIds.SseEvent) return shape.typeArgs[0]
1406
+ for (const arg of shape.typeArgs) {
1407
+ const found = eventData(arg)
1408
+ if (found !== undefined) return found
1409
+ }
1410
+ return undefined
1411
+ }
1412
+ if (shape.kind === 'union') {
1413
+ for (const member of shape.members) {
1414
+ const found = eventData(member)
1415
+ if (found !== undefined) return found
1416
+ }
1417
+ }
1418
+ return undefined
1419
+ }
1420
+
1421
+ /** JSON Schema of a coercion plan (§5.4.2). */
1422
+ export function planSchema(plan: CoercionPlan): JsonSchema {
1423
+ switch (plan.kind) {
1424
+ case 'string':
1425
+ case 'number':
1426
+ case 'integer':
1427
+ case 'boolean':
1428
+ return { type: plan.kind }
1429
+ case 'bigint':
1430
+ return { type: 'string', pattern: '^-?[0-9]+$' }
1431
+ case 'date':
1432
+ return { type: 'string', format: 'date-time' }
1433
+ case 'enum': {
1434
+ const types = [...new Set(plan.values.map(value => typeof value))]
1435
+ return types.length === 1 ? { type: types[0] as string, enum: [...plan.values] } : { enum: [...plan.values] }
1436
+ }
1437
+ case 'array':
1438
+ return { type: 'array', items: planSchema(plan.item) as JsonValue }
1439
+ case 'optional':
1440
+ return planSchema(plan.item)
1441
+ case 'raw':
1442
+ return {}
1443
+ }
1444
+ }
1445
+
1446
+ /** `2 MiB`, `512 KiB`, `1000 bytes`. */
1447
+ export function formatSize(bytes: number): string {
1448
+ const units = ['GiB', 'MiB', 'KiB'] as const
1449
+ const factors = [1024 ** 3, 1024 ** 2, 1024]
1450
+ for (const [index, unit] of units.entries()) {
1451
+ const factor = factors[index] as number
1452
+ if (bytes >= factor && bytes % (factor / 4) === 0) return `${bytes / factor} ${unit}`
1453
+ }
1454
+ return `${bytes} bytes`
1455
+ }
1456
+
1457
+ /** Status keys: codes first in numeric order, then ranges, then `default`. */
1458
+ function compareStatuses(a: string, b: string): number {
1459
+ const rank = (status: string): number => {
1460
+ if (status === 'default') return 2
1461
+ return /X/.test(status) ? 1 : 0
1462
+ }
1463
+ return rank(a) - rank(b) || compareStrings(a, b)
1464
+ }
1465
+
1466
+ /** A handler name as written in a class body: `get`, or `'get-item'` for a quoted key. */
1467
+ function handlerKey(name: string): string {
1468
+ return /^[A-Za-z_$][\w$]*$/.test(name) ? name : `'${name.replace(/['\\]/g, '\\$&')}'`
1469
+ }
1470
+
1471
+ /** The body type named by the hints (`ItemController` → `ItemResponse`): an example, never a literal `Body`. */
1472
+ function exampleBodyType(controller: string): string {
1473
+ const base = controller.replace(/Controller$/, '')
1474
+ return base === '' ? 'ResponseBody' : `${base}Response`
1475
+ }