@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.
- package/LICENSE +21 -0
- package/README.md +572 -2
- package/dist/build-BTUC96mY.d.mts +174 -0
- package/dist/build-DXunOzlI.mjs +733 -0
- package/dist/build-DXunOzlI.mjs.map +1 -0
- package/dist/compiler.d.mts +16 -0
- package/dist/compiler.mjs +2639 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/emit-child.d.mts +1 -0
- package/dist/emit-child.mjs +124 -0
- package/dist/emit-child.mjs.map +1 -0
- package/dist/emit.d.mts +2 -0
- package/dist/emit.mjs +252 -0
- package/dist/emit.mjs.map +1 -0
- package/dist/index-CqQ6IRnN.d.mts +60 -0
- package/dist/index.d.mts +260 -0
- package/dist/index.mjs +777 -0
- package/dist/index.mjs.map +1 -0
- package/dist/openapi-CQk5dHqc.mjs +39 -0
- package/dist/openapi-CQk5dHqc.mjs.map +1 -0
- package/dist/shared-BZxtyaB0.mjs +147 -0
- package/dist/shared-BZxtyaB0.mjs.map +1 -0
- package/dist/testing.d.mts +39 -0
- package/dist/testing.mjs +155 -0
- package/dist/testing.mjs.map +1 -0
- package/dist/types-VzBexMKj.d.mts +213 -0
- package/dist/vite.d.mts +24 -0
- package/dist/vite.mjs +64 -0
- package/dist/vite.mjs.map +1 -0
- package/package.json +115 -4
- package/src/build.ts +898 -0
- package/src/compiler/analyze.ts +81 -0
- package/src/compiler/document.ts +612 -0
- package/src/compiler/generate.ts +79 -0
- package/src/compiler/index.ts +47 -0
- package/src/compiler/model.ts +141 -0
- package/src/compiler/operations.ts +1475 -0
- package/src/compiler/schemas.ts +706 -0
- package/src/compiler/validate.ts +102 -0
- package/src/compiler/values.ts +81 -0
- package/src/converters.ts +137 -0
- package/src/decorators.ts +187 -0
- package/src/emit/child.ts +144 -0
- package/src/emit/index.ts +377 -0
- package/src/emit/protocol.ts +35 -0
- package/src/index.ts +94 -0
- package/src/metadata.ts +112 -0
- package/src/openapi.ts +55 -0
- package/src/plugin.ts +810 -0
- package/src/shared.ts +154 -0
- package/src/testing.ts +224 -0
- package/src/types.ts +240 -0
- package/src/ui.ts +215 -0
- 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
|
+
}
|