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