@znight/openapi 0.0.0-stage → 0.1.1
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,706 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeScript shapes (`ctx.types.shape()`, C1 of RFC 0002) → JSON Schema 2020-12 (§6.2): named
|
|
3
|
+
* declarations become components (`#/components/schemas/<name>`), JSDoc tags become keywords,
|
|
4
|
+
* Standard Schema aliases (`z.output<typeof S>`) become placeholders converted at assembly.
|
|
5
|
+
*/
|
|
6
|
+
import type {
|
|
7
|
+
DocComment,
|
|
8
|
+
Location,
|
|
9
|
+
NamedShape,
|
|
10
|
+
Shape,
|
|
11
|
+
ShapeIssue,
|
|
12
|
+
ShapeOptions,
|
|
13
|
+
ShapeProperty,
|
|
14
|
+
ShapeResult,
|
|
15
|
+
ShapeTarget,
|
|
16
|
+
ValidateContext,
|
|
17
|
+
ValueRef,
|
|
18
|
+
} from '@znight/compiler'
|
|
19
|
+
import type { SchemaPlaceholder, SchemaTemplate } from '../metadata.ts'
|
|
20
|
+
import type { JsonSchema, JsonValue } from '../types.ts'
|
|
21
|
+
import type { OpenApiStored, SlotData } from './model.ts'
|
|
22
|
+
|
|
23
|
+
import { createHash } from 'node:crypto'
|
|
24
|
+
|
|
25
|
+
import { compareStrings, componentName, schemaRef } from '../shared.ts'
|
|
26
|
+
import { Codes, KEEP, SCHEMA_ALIASES, SCHEMA_MEMBERS } from './model.ts'
|
|
27
|
+
|
|
28
|
+
type Context = ValidateContext<OpenApiStored>
|
|
29
|
+
type Diagnostic = Parameters<Context['report']>[0]
|
|
30
|
+
|
|
31
|
+
/** How a schema is used (messages and severities of ZNIGHT032). */
|
|
32
|
+
export interface SchemaUse {
|
|
33
|
+
/** `'output'`: a response body (a `bigint` makes `JSON.stringify` throw: always an error). */
|
|
34
|
+
readonly io: 'input' | 'output'
|
|
35
|
+
/** `GET /books/stats (BookController.stats)`. */
|
|
36
|
+
readonly operation: string
|
|
37
|
+
/** `the response`, `the request body`, `query parameter "q"`. */
|
|
38
|
+
readonly part: string
|
|
39
|
+
/** Where the type is used (the handler, the decorator...). */
|
|
40
|
+
readonly location: Location | undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Standard Schemas referenced by the document, by identity. */
|
|
44
|
+
export class Slots {
|
|
45
|
+
readonly #slots = new Map<string, SlotData>()
|
|
46
|
+
readonly #names = new Map<string, { identity: string; location: Location }>()
|
|
47
|
+
readonly #report: (diagnostic: Diagnostic) => void
|
|
48
|
+
readonly #reserved: () => ReadonlySet<string>
|
|
49
|
+
readonly #locate: (ref: ValueRef) => Location | undefined
|
|
50
|
+
|
|
51
|
+
constructor(
|
|
52
|
+
report: (diagnostic: Diagnostic) => void,
|
|
53
|
+
reserved: () => ReadonlySet<string>,
|
|
54
|
+
locate: (ref: ValueRef) => Location | undefined = () => undefined,
|
|
55
|
+
) {
|
|
56
|
+
this.#report = report
|
|
57
|
+
this.#reserved = reserved
|
|
58
|
+
this.#locate = locate
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The placeholder of `ref` with this io (registers the slot), `undefined` after a name conflict. */
|
|
62
|
+
use(ref: ValueRef, io: 'input' | 'output'): SchemaPlaceholder | undefined {
|
|
63
|
+
const identity = ref.identity ?? `${ref.location.file}#${ref.text}`
|
|
64
|
+
let slot = this.#slots.get(identity)
|
|
65
|
+
if (slot === undefined) {
|
|
66
|
+
const name = componentName(ref.name.slice(ref.name.lastIndexOf('.') + 1))
|
|
67
|
+
const other = this.#names.get(name)
|
|
68
|
+
if (other !== undefined && other.identity !== identity) {
|
|
69
|
+
this.#report({
|
|
70
|
+
code: Codes.SchemaNameConflict,
|
|
71
|
+
severity: 'error',
|
|
72
|
+
message: `Two schemas are named "${name}" in the OpenAPI document: ${ref.text} and ${other.identity}`,
|
|
73
|
+
location: ref.location,
|
|
74
|
+
related: [{ message: 'the other schema', location: other.location }],
|
|
75
|
+
hints: ['Rename one of the exported schemas: component names come from their export names'],
|
|
76
|
+
})
|
|
77
|
+
return undefined
|
|
78
|
+
}
|
|
79
|
+
if (this.#reserved().has(name)) {
|
|
80
|
+
this.#report({
|
|
81
|
+
code: Codes.SchemaNameConflict,
|
|
82
|
+
severity: 'error',
|
|
83
|
+
message: `The schema ${ref.text} is named "${name}", a name reserved for the standard error components`,
|
|
84
|
+
location: ref.location,
|
|
85
|
+
hints: ['Rename the schema, or turn the standard errors off: @OpenApiDocument({ errors: false })'],
|
|
86
|
+
})
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
this.#names.set(name, { identity, location: ref.location })
|
|
90
|
+
slot = { name, ref, io: new Set(), location: this.#locate(ref) ?? ref.location }
|
|
91
|
+
this.#slots.set(identity, slot)
|
|
92
|
+
}
|
|
93
|
+
slot.io.add(io)
|
|
94
|
+
return { $znight: 'schema', name: slot.name, io }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Whether a slot uses this component name. */
|
|
98
|
+
has(name: string): { identity: string; location: Location } | undefined {
|
|
99
|
+
return this.#names.get(name)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Adds an io to the slot of `identity` (no effect for an unknown slot). */
|
|
103
|
+
need(identity: string, io: 'input' | 'output'): void {
|
|
104
|
+
this.#slots.get(identity)?.io.add(io)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Slots sorted by name. */
|
|
108
|
+
list(): SlotData[] {
|
|
109
|
+
return [...this.#slots.values()].sort((a, b) => compareStrings(a.name, b.name))
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A component computed from a TypeScript declaration. */
|
|
114
|
+
interface Component {
|
|
115
|
+
readonly key: string
|
|
116
|
+
readonly identity: string
|
|
117
|
+
readonly location: Location
|
|
118
|
+
schema: JsonSchema | undefined
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Converts shapes to JSON Schema and collects the components. */
|
|
122
|
+
export class Schemas {
|
|
123
|
+
readonly #ctx: Context
|
|
124
|
+
readonly #options: ShapeOptions
|
|
125
|
+
readonly #slots: Slots
|
|
126
|
+
readonly #strict: boolean
|
|
127
|
+
readonly #reserved: ReadonlySet<string>
|
|
128
|
+
/** Component name by named key. */
|
|
129
|
+
readonly #names = new Map<string, string>()
|
|
130
|
+
/** The first named key given each component name. */
|
|
131
|
+
readonly #keys = new Map<string, string>()
|
|
132
|
+
/** Components by name. */
|
|
133
|
+
readonly #components = new Map<string, Component>()
|
|
134
|
+
/** Every named shape seen, by key. */
|
|
135
|
+
readonly #named = new Map<string, NamedShape>()
|
|
136
|
+
/** Issues reported so far, with the severity they were reported at. */
|
|
137
|
+
readonly #reported = new WeakMap<object, 'warning' | 'error'>()
|
|
138
|
+
readonly #conflicts = new Set<string>()
|
|
139
|
+
|
|
140
|
+
constructor(ctx: Context, options: { jsdoc: boolean; strict: boolean; reserved: ReadonlySet<string> }, slots: Slots) {
|
|
141
|
+
this.#ctx = ctx
|
|
142
|
+
this.#slots = slots
|
|
143
|
+
this.#strict = options.strict
|
|
144
|
+
this.#reserved = options.reserved
|
|
145
|
+
this.#options = {
|
|
146
|
+
schemaAliases: SCHEMA_ALIASES,
|
|
147
|
+
schemaMembers: SCHEMA_MEMBERS,
|
|
148
|
+
keep: KEEP,
|
|
149
|
+
jsdoc: options.jsdoc,
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The shape of a target with the options of the extension. */
|
|
154
|
+
shape(target: ShapeTarget): ShapeResult {
|
|
155
|
+
return this.#ctx.types.shape(target, this.#options)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Components computed so far, sorted by name. */
|
|
159
|
+
components(): Record<string, JsonSchema> {
|
|
160
|
+
const result: Record<string, JsonSchema> = {}
|
|
161
|
+
for (const name of [...this.#components.keys()].sort(compareStrings)) {
|
|
162
|
+
const schema = this.#components.get(name)?.schema
|
|
163
|
+
if (schema !== undefined) result[name] = schema
|
|
164
|
+
}
|
|
165
|
+
return result
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A named shape seen by a result (`undefined` for an unknown key). */
|
|
169
|
+
named(key: string): NamedShape | undefined {
|
|
170
|
+
return this.#named.get(key)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Registers the named shapes of `result` and reports its issues. Call it before converting shapes
|
|
175
|
+
* of the result.
|
|
176
|
+
*/
|
|
177
|
+
register(result: ShapeResult, use: SchemaUse): void {
|
|
178
|
+
for (const named of result.named.values()) this.#named.set(named.key, named)
|
|
179
|
+
for (const named of result.named.values()) this.#component(named)
|
|
180
|
+
for (const issue of result.issues) this.#issue(issue, use)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** JSON Schema of the root of `result` (registers it first). */
|
|
184
|
+
fromResult(result: ShapeResult, use: SchemaUse): SchemaTemplate {
|
|
185
|
+
this.register(result, use)
|
|
186
|
+
return this.convert(result.root, use, '')
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** JSON Schema of a shape whose result was {@link register}ed. */
|
|
190
|
+
convert(shape: Shape, use: SchemaUse, path: string): SchemaTemplate {
|
|
191
|
+
switch (shape.kind) {
|
|
192
|
+
case 'string':
|
|
193
|
+
return { type: 'string' }
|
|
194
|
+
case 'number':
|
|
195
|
+
return { type: 'number' }
|
|
196
|
+
case 'boolean':
|
|
197
|
+
return { type: 'boolean' }
|
|
198
|
+
case 'null':
|
|
199
|
+
return { type: 'null' }
|
|
200
|
+
case 'undefined':
|
|
201
|
+
case 'unknown':
|
|
202
|
+
return {}
|
|
203
|
+
case 'never':
|
|
204
|
+
return { not: {} }
|
|
205
|
+
case 'date':
|
|
206
|
+
return { type: 'string', format: 'date-time' }
|
|
207
|
+
case 'url':
|
|
208
|
+
return { type: 'string', format: 'uri' }
|
|
209
|
+
case 'bigint':
|
|
210
|
+
// reported through the issues of the result
|
|
211
|
+
return { type: 'string', pattern: '^-?[0-9]+$' }
|
|
212
|
+
case 'binary':
|
|
213
|
+
if (path !== '') {
|
|
214
|
+
this.#warn(
|
|
215
|
+
`${path} is binary data: inside a JSON body JSON.stringify writes it as an object of numeric keys (${use.operation})`,
|
|
216
|
+
use,
|
|
217
|
+
['Send bytes as the whole body (application/octet-stream), or encode them (base64 string)'],
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
return { type: 'string', contentMediaType: 'application/octet-stream' }
|
|
221
|
+
case 'literal':
|
|
222
|
+
return { type: typeof shape.value as 'string' | 'number' | 'boolean', const: shape.value }
|
|
223
|
+
case 'array':
|
|
224
|
+
return { type: 'array', items: this.convert(shape.items, use, `${path}[]`) as JsonValue }
|
|
225
|
+
case 'tuple':
|
|
226
|
+
return this.#tuple(shape, use, path)
|
|
227
|
+
case 'object':
|
|
228
|
+
return this.#object(shape.properties, shape.additional, use, path)
|
|
229
|
+
case 'union':
|
|
230
|
+
return this.#union(shape.members, use, path)
|
|
231
|
+
case 'intersection':
|
|
232
|
+
return this.#intersection(shape.members, use, path)
|
|
233
|
+
case 'named': {
|
|
234
|
+
const name = this.#nameOfKey(shape.key)
|
|
235
|
+
return name === undefined ? {} : { $ref: schemaRef(name) }
|
|
236
|
+
}
|
|
237
|
+
case 'schema':
|
|
238
|
+
return this.#slots.use(shape.ref, shape.io) ?? {}
|
|
239
|
+
case 'reference':
|
|
240
|
+
this.#warn(
|
|
241
|
+
`${path === '' ? 'The type' : path} (${shape.name}) cannot be documented inside a JSON body (${use.operation})`,
|
|
242
|
+
use,
|
|
243
|
+
[],
|
|
244
|
+
)
|
|
245
|
+
return {}
|
|
246
|
+
case 'opaque':
|
|
247
|
+
// reported through the issues of the result
|
|
248
|
+
return {}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ------------------------------------------------------------------------------------------------
|
|
253
|
+
// components
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Checks the components against the schema slots once every operation is built, whatever the order
|
|
257
|
+
* they were met in: a declaration merged with the schema it is inferred from (`const User =
|
|
258
|
+
* z.object(...)` and `interface User extends z.infer<typeof User> {}`) is documented by the schema
|
|
259
|
+
* (its output variant); another declaration with the name of a schema is ZNIGHT034.
|
|
260
|
+
*/
|
|
261
|
+
settle(): void {
|
|
262
|
+
for (const [name, component] of [...this.#components]) {
|
|
263
|
+
const slot = this.#slots.has(name)
|
|
264
|
+
if (slot === undefined) continue
|
|
265
|
+
if (slot.identity === component.identity) {
|
|
266
|
+
this.#components.delete(name)
|
|
267
|
+
this.#slots.need(slot.identity, 'output')
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
const named = this.#named.get(component.key)
|
|
271
|
+
const key = `${name}\u0000${component.key}`
|
|
272
|
+
if (this.#conflicts.has(key)) continue
|
|
273
|
+
this.#conflicts.add(key)
|
|
274
|
+
this.#ctx.report({
|
|
275
|
+
code: Codes.SchemaNameConflict,
|
|
276
|
+
severity: 'error',
|
|
277
|
+
message: `Two schemas are named "${name}" in the OpenAPI document: the ${named?.declaration ?? 'declaration'} ${named?.name ?? component.identity} and the schema ${slot.identity}`,
|
|
278
|
+
location: component.location,
|
|
279
|
+
related: [{ message: 'the schema', location: slot.location }],
|
|
280
|
+
hints: ['Rename one of them: component names come from declaration and export names'],
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
#component(named: NamedShape): void {
|
|
286
|
+
const name = this.#nameOf(named)
|
|
287
|
+
const existing = this.#components.get(name)
|
|
288
|
+
if (existing !== undefined) {
|
|
289
|
+
if (existing.key !== named.key) this.#conflict(name, named, existing)
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
if (this.#reserved.has(name)) {
|
|
293
|
+
const key = `${name}\u0000${named.key}`
|
|
294
|
+
if (!this.#conflicts.has(key)) {
|
|
295
|
+
this.#conflicts.add(key)
|
|
296
|
+
this.#ctx.report({
|
|
297
|
+
code: Codes.SchemaNameConflict,
|
|
298
|
+
severity: 'error',
|
|
299
|
+
message: `${named.name} is named "${name}", a name reserved for the standard error components of the OpenAPI document`,
|
|
300
|
+
location: named.location,
|
|
301
|
+
hints: ['Rename one of them: component names come from declaration and export names'],
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
const component: Component = {
|
|
307
|
+
key: named.key,
|
|
308
|
+
identity: named.identity,
|
|
309
|
+
location: named.location,
|
|
310
|
+
schema: undefined,
|
|
311
|
+
}
|
|
312
|
+
this.#components.set(name, component)
|
|
313
|
+
const use: SchemaUse = { io: 'output', operation: named.name, part: named.name, location: named.location }
|
|
314
|
+
const schema = this.convert(named.shape, use, named.name)
|
|
315
|
+
component.schema = withDoc(schema as JsonSchema, named.doc, issue => this.#tagIssue(issue))
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
#conflict(name: string, named: NamedShape, existing: Component): void {
|
|
319
|
+
const key = [named.key, existing.key].sort(compareStrings).join('\u0000')
|
|
320
|
+
if (this.#conflicts.has(key)) return
|
|
321
|
+
this.#conflicts.add(key)
|
|
322
|
+
this.#ctx.report({
|
|
323
|
+
code: Codes.SchemaNameConflict,
|
|
324
|
+
severity: 'error',
|
|
325
|
+
message: `Two declarations are named "${name}" in the OpenAPI document: ${named.identity} and ${existing.identity}`,
|
|
326
|
+
location: named.location,
|
|
327
|
+
related: [{ message: 'the other declaration', location: existing.location }],
|
|
328
|
+
hints: ['Rename one of them: component names come from declaration names'],
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
#nameOfKey(key: string): string | undefined {
|
|
333
|
+
const named = this.#named.get(key)
|
|
334
|
+
return named === undefined ? undefined : this.#nameOf(named)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The component name of a declaration: its name, then the names of its type arguments
|
|
339
|
+
* (`Page_Book`). An instantiation with an anonymous argument (`Page<{ id: string }>`), or whose
|
|
340
|
+
* name another instantiation of the same declaration already has (`Box<Book[]>`, `Box<BookArray>`),
|
|
341
|
+
* also gets a short hash of its key (`Page_object_1a2b3c4d`): two instantiations never clash.
|
|
342
|
+
*/
|
|
343
|
+
#nameOf(named: NamedShape): string {
|
|
344
|
+
let name = this.#names.get(named.key)
|
|
345
|
+
if (name === undefined) {
|
|
346
|
+
if (named.typeArgs.length === 0) name = componentName(named.name)
|
|
347
|
+
else {
|
|
348
|
+
const args = named.typeArgs.map(arg => this.#argName(arg))
|
|
349
|
+
name = componentName([named.name, ...args.map(arg => arg.text)].join('_'))
|
|
350
|
+
const taken = this.#keys.get(name)
|
|
351
|
+
const sibling =
|
|
352
|
+
taken !== undefined && taken !== named.key && this.#named.get(taken)?.identity === named.identity
|
|
353
|
+
if (sibling || args.some(arg => !arg.exact)) name = `${name}_${shortHash(named.key)}`
|
|
354
|
+
}
|
|
355
|
+
this.#names.set(named.key, name)
|
|
356
|
+
if (!this.#keys.has(name)) this.#keys.set(name, named.key)
|
|
357
|
+
}
|
|
358
|
+
return name
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** The name of a type argument; `exact: false` when it does not tell the argument apart (`object`). */
|
|
362
|
+
#argName(shape: Shape): { text: string; exact: boolean } {
|
|
363
|
+
switch (shape.kind) {
|
|
364
|
+
case 'named':
|
|
365
|
+
return { text: this.#nameOfKey(shape.key) ?? 'unknown', exact: true }
|
|
366
|
+
case 'literal':
|
|
367
|
+
return { text: String(shape.value), exact: true }
|
|
368
|
+
case 'array': {
|
|
369
|
+
const items = this.#argName(shape.items)
|
|
370
|
+
return { text: `${items.text}Array`, exact: items.exact }
|
|
371
|
+
}
|
|
372
|
+
case 'union':
|
|
373
|
+
case 'intersection': {
|
|
374
|
+
const members = shape.members.map(member => this.#argName(member))
|
|
375
|
+
return {
|
|
376
|
+
text: members.map(member => member.text).join(shape.kind === 'union' ? '_or_' : '_and_'),
|
|
377
|
+
exact: members.every(member => member.exact),
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
case 'schema':
|
|
381
|
+
return { text: componentName(shape.ref.name), exact: true }
|
|
382
|
+
case 'reference':
|
|
383
|
+
return { text: shape.name, exact: shape.typeArgs.length === 0 }
|
|
384
|
+
case 'opaque':
|
|
385
|
+
case 'object':
|
|
386
|
+
case 'tuple':
|
|
387
|
+
return { text: shape.kind, exact: false }
|
|
388
|
+
default:
|
|
389
|
+
return { text: shape.kind, exact: true }
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ------------------------------------------------------------------------------------------------
|
|
394
|
+
// structures
|
|
395
|
+
|
|
396
|
+
#tuple(shape: Extract<Shape, { kind: 'tuple' }>, use: SchemaUse, path: string): JsonSchema {
|
|
397
|
+
const prefixItems = shape.items.map((item, index) => this.convert(item, use, `${path}[${index}]`) as JsonValue)
|
|
398
|
+
const schema: Record<string, JsonValue> = { type: 'array', prefixItems, minItems: shape.minItems }
|
|
399
|
+
if (shape.rest === undefined) {
|
|
400
|
+
schema['maxItems'] = shape.items.length
|
|
401
|
+
schema['items'] = false
|
|
402
|
+
} else {
|
|
403
|
+
schema['items'] = this.convert(shape.rest, use, `${path}[]`) as JsonValue
|
|
404
|
+
}
|
|
405
|
+
return schema
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
#object(
|
|
409
|
+
properties: readonly ShapeProperty[],
|
|
410
|
+
additional: Shape | undefined,
|
|
411
|
+
use: SchemaUse,
|
|
412
|
+
path: string,
|
|
413
|
+
): JsonSchema {
|
|
414
|
+
const schema: Record<string, JsonValue> = { type: 'object' }
|
|
415
|
+
if (properties.length > 0) {
|
|
416
|
+
const props: Record<string, JsonValue> = {}
|
|
417
|
+
for (const property of properties) {
|
|
418
|
+
const converted = this.convert(property.shape, use, path === '' ? property.name : `${path}.${property.name}`)
|
|
419
|
+
props[property.name] = withDoc(converted as JsonSchema, property.doc, issue =>
|
|
420
|
+
this.#tagIssue(issue),
|
|
421
|
+
) as JsonValue
|
|
422
|
+
}
|
|
423
|
+
schema['properties'] = props
|
|
424
|
+
const required = properties
|
|
425
|
+
.filter(property => !property.optional)
|
|
426
|
+
.map(property => property.name)
|
|
427
|
+
.sort(compareStrings)
|
|
428
|
+
if (required.length > 0) schema['required'] = required
|
|
429
|
+
}
|
|
430
|
+
if (additional !== undefined) {
|
|
431
|
+
schema['additionalProperties'] = this.convert(additional, use, `${path}[string]`) as JsonValue
|
|
432
|
+
}
|
|
433
|
+
return schema
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
#union(members: readonly Shape[], use: SchemaUse, path: string): SchemaTemplate {
|
|
437
|
+
const defined = members.filter(member => member.kind !== 'undefined')
|
|
438
|
+
const nullable = defined.some(member => member.kind === 'null')
|
|
439
|
+
const rest = defined.filter(member => member.kind !== 'null')
|
|
440
|
+
if (rest.length === 0) return nullable ? { type: 'null' } : {}
|
|
441
|
+
if (rest.every(member => member.kind === 'literal')) {
|
|
442
|
+
const values = rest.map(member => (member as Extract<Shape, { kind: 'literal' }>).value)
|
|
443
|
+
const types = [...new Set(values.map(value => typeof value))]
|
|
444
|
+
const schema: Record<string, JsonValue> = {}
|
|
445
|
+
if (types.length === 1) schema['type'] = nullable ? [types[0] as string, 'null'] : (types[0] as string)
|
|
446
|
+
schema['enum'] = nullable ? [...values, null] : values
|
|
447
|
+
return schema
|
|
448
|
+
}
|
|
449
|
+
const converted = rest.map(member => this.convert(member, use, path))
|
|
450
|
+
if (converted.length === 1) {
|
|
451
|
+
const single = converted[0] as JsonSchema
|
|
452
|
+
if (!nullable) return single
|
|
453
|
+
if (typeof single['type'] === 'string' && !Object.keys(single).some(key => NOT_NULLABLE_KEYS.has(key))) {
|
|
454
|
+
return { ...single, type: [single['type'], 'null'] }
|
|
455
|
+
}
|
|
456
|
+
return { anyOf: [single as JsonValue, { type: 'null' }] }
|
|
457
|
+
}
|
|
458
|
+
// only simple `{ type }` members: a type list
|
|
459
|
+
const simple = converted as readonly JsonSchema[]
|
|
460
|
+
if (simple.every(schema => Object.keys(schema).length === 1 && typeof schema['type'] === 'string')) {
|
|
461
|
+
const types = simple.map(schema => schema['type'] as string)
|
|
462
|
+
return { type: nullable ? [...types, 'null'] : types }
|
|
463
|
+
}
|
|
464
|
+
const discriminator = this.#discriminator(rest)
|
|
465
|
+
const list: JsonValue[] = converted.map(schema => schema as JsonValue)
|
|
466
|
+
if (nullable) list.push({ type: 'null' })
|
|
467
|
+
if (discriminator === undefined) return { anyOf: list }
|
|
468
|
+
const mapping: Record<string, string> = {}
|
|
469
|
+
rest.forEach((member, index) => {
|
|
470
|
+
const ref = (converted[index] as JsonSchema)['$ref']
|
|
471
|
+
if (member.kind === 'named' && typeof ref === 'string') {
|
|
472
|
+
mapping[String(discriminator.values[index])] = ref
|
|
473
|
+
}
|
|
474
|
+
})
|
|
475
|
+
const schema: Record<string, JsonValue> = { oneOf: list }
|
|
476
|
+
// without a mapping, tools read the values as component names: only when every member is a component
|
|
477
|
+
if (Object.keys(mapping).length === rest.length) {
|
|
478
|
+
schema['discriminator'] = { propertyName: discriminator.property, mapping }
|
|
479
|
+
}
|
|
480
|
+
return schema
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** A required literal property with distinct (string) values in every object member. */
|
|
484
|
+
#discriminator(members: readonly Shape[]): { property: string; values: (string | number | boolean)[] } | undefined {
|
|
485
|
+
const objects = members.map(member => this.#objectOf(member))
|
|
486
|
+
if (objects.some(object => object === undefined)) return undefined
|
|
487
|
+
const literals = objects.map(object => {
|
|
488
|
+
const map = new Map<string, string | number | boolean>()
|
|
489
|
+
for (const property of object?.properties ?? []) {
|
|
490
|
+
if (!property.optional && property.shape.kind === 'literal' && typeof property.shape.value === 'string') {
|
|
491
|
+
map.set(property.name, property.shape.value)
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return map
|
|
495
|
+
})
|
|
496
|
+
const first = literals[0]
|
|
497
|
+
if (first === undefined) return undefined
|
|
498
|
+
for (const property of [...first.keys()].sort(compareStrings)) {
|
|
499
|
+
if (!literals.every(map => map.has(property))) continue
|
|
500
|
+
const values = literals.map(map => map.get(property) as string | number | boolean)
|
|
501
|
+
if (new Set(values).size === values.length) return { property, values }
|
|
502
|
+
}
|
|
503
|
+
return undefined
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** The object structure of a shape (named declarations resolved). */
|
|
507
|
+
#objectOf(shape: Shape): Extract<Shape, { kind: 'object' }> | undefined {
|
|
508
|
+
if (shape.kind === 'object') return shape
|
|
509
|
+
if (shape.kind === 'named') {
|
|
510
|
+
const named = this.#named.get(shape.key)
|
|
511
|
+
return named === undefined ? undefined : this.#objectOf(named.shape)
|
|
512
|
+
}
|
|
513
|
+
if (shape.kind === 'intersection') {
|
|
514
|
+
const parts = shape.members.map(member => this.#objectOf(member))
|
|
515
|
+
if (parts.some(part => part === undefined)) return undefined
|
|
516
|
+
const properties = new Map<string, ShapeProperty>()
|
|
517
|
+
for (const part of parts) for (const property of part?.properties ?? []) properties.set(property.name, property)
|
|
518
|
+
return { kind: 'object', properties: [...properties.values()] }
|
|
519
|
+
}
|
|
520
|
+
return undefined
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
#intersection(members: readonly Shape[], use: SchemaUse, path: string): SchemaTemplate {
|
|
524
|
+
const merged = this.#objectOf({ kind: 'intersection', members })
|
|
525
|
+
if (merged !== undefined) {
|
|
526
|
+
const additional = members.map(member => this.#objectOf(member)?.additional).find(value => value !== undefined)
|
|
527
|
+
return this.#object(merged.properties, additional, use, path)
|
|
528
|
+
}
|
|
529
|
+
return { allOf: members.map(member => this.convert(member, use, path) as JsonValue) }
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ------------------------------------------------------------------------------------------------
|
|
533
|
+
// diagnostics
|
|
534
|
+
|
|
535
|
+
#issue(issue: ShapeIssue, use: SchemaUse): void {
|
|
536
|
+
// one report per issue, unless a later use is more severe (a bigint met in a request body, then in a response)
|
|
537
|
+
const severity = (issue.reason === 'bigint' && use.io === 'output') || this.#strict ? 'error' : 'warning'
|
|
538
|
+
const reported = this.#reported.get(issue)
|
|
539
|
+
if (reported === 'error' || reported === severity) return
|
|
540
|
+
this.#reported.set(issue, severity)
|
|
541
|
+
const subject = issue.path === '' ? `The type of ${use.part}` : issue.path
|
|
542
|
+
const location = issue.location ?? use.location
|
|
543
|
+
const related =
|
|
544
|
+
issue.location !== undefined && use.location !== undefined
|
|
545
|
+
? [{ message: `${use.part} of ${use.operation}`, location: use.location }]
|
|
546
|
+
: []
|
|
547
|
+
if (issue.reason === 'bigint') {
|
|
548
|
+
const response = use.io === 'output'
|
|
549
|
+
this.#ctx.report({
|
|
550
|
+
code: Codes.UnrepresentableType,
|
|
551
|
+
severity,
|
|
552
|
+
message: response
|
|
553
|
+
? `${subject} is a bigint: JSON.stringify throws on bigint, so ${use.operation} would answer 500`
|
|
554
|
+
: `${subject} is a bigint: JSON has no bigint (${use.operation})`,
|
|
555
|
+
...(location === undefined ? {} : { location }),
|
|
556
|
+
related,
|
|
557
|
+
hints: [
|
|
558
|
+
response
|
|
559
|
+
? 'Return a number or a string (String(total)), or give the type a toJSON() method'
|
|
560
|
+
: 'Declare a string and convert it (BigInt(value)), or validate the body with a schema',
|
|
561
|
+
],
|
|
562
|
+
})
|
|
563
|
+
return
|
|
564
|
+
}
|
|
565
|
+
this.#ctx.report({
|
|
566
|
+
code: Codes.UnrepresentableType,
|
|
567
|
+
severity,
|
|
568
|
+
message: `${subject} (${issue.text}) ${REASONS[issue.reason] ?? 'has no JSON Schema'}: it is documented as any value (${use.operation})`,
|
|
569
|
+
...(location === undefined ? {} : { location }),
|
|
570
|
+
related,
|
|
571
|
+
hints: HINTS[issue.reason] ?? [],
|
|
572
|
+
})
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
#warn(message: string, use: SchemaUse, hints: readonly string[]): void {
|
|
576
|
+
this.#ctx.report({
|
|
577
|
+
code: Codes.UnrepresentableType,
|
|
578
|
+
severity: this.#strict ? 'error' : 'warning',
|
|
579
|
+
message,
|
|
580
|
+
...(use.location === undefined ? {} : { location: use.location }),
|
|
581
|
+
hints,
|
|
582
|
+
})
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
#tagIssue(issue: TagIssue): void {
|
|
586
|
+
this.#ctx.report({
|
|
587
|
+
code: Codes.UnrepresentableType,
|
|
588
|
+
severity: this.#strict ? 'error' : 'warning',
|
|
589
|
+
message: issue.message,
|
|
590
|
+
location: issue.location,
|
|
591
|
+
hints: ['JSDoc tag values are JSON (`@minimum 1`, `@example {"id": 1}`) or text'],
|
|
592
|
+
})
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** 8 hexadecimal characters of the SHA-256 of `text` (deterministic name suffixes). */
|
|
597
|
+
function shortHash(text: string): string {
|
|
598
|
+
return createHash('sha256').update(text).digest('hex').slice(0, 8)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Keywords whose meaning changes when `null` joins the type list (`enum`, `const`...): `anyOf` instead. */
|
|
602
|
+
const NOT_NULLABLE_KEYS = new Set(['$ref', 'enum', 'const', 'anyOf', 'oneOf', 'allOf', 'not'])
|
|
603
|
+
|
|
604
|
+
const REASONS: Readonly<Record<string, string>> = {
|
|
605
|
+
function: 'is a function, which JSON.stringify skips',
|
|
606
|
+
symbol: 'is a symbol, which JSON.stringify skips',
|
|
607
|
+
'type-parameter': 'is an unbound type parameter',
|
|
608
|
+
'non-json': 'is not serialised as declared by JSON.stringify',
|
|
609
|
+
mapped: 'is a mapped type the compiler cannot expand',
|
|
610
|
+
conditional: 'is a conditional type the compiler cannot expand',
|
|
611
|
+
typeof: 'is a typeof type that is not a schema',
|
|
612
|
+
'indexed-access': 'is an indexed access type the compiler cannot resolve',
|
|
613
|
+
'type-operator': 'uses a type operator the compiler cannot expand',
|
|
614
|
+
this: 'is a this type',
|
|
615
|
+
unresolved: 'cannot be resolved',
|
|
616
|
+
'missing-annotation': 'has no type annotation',
|
|
617
|
+
'to-json': 'has a toJSON() method without return type annotation',
|
|
618
|
+
recursive: 'is a recursion the compiler cannot follow',
|
|
619
|
+
unsupported: 'has no JSON Schema',
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const HINTS: Readonly<Record<string, readonly string[]>> = {
|
|
623
|
+
'non-json': ['Map, Set, RegExp and Error become {} in JSON: use an array or a record, or add a toJSON() method'],
|
|
624
|
+
'to-json': ['Annotate the return type of toJSON(): toJSON(): BookJson'],
|
|
625
|
+
'missing-annotation': ['Annotate the type: the compiler reads annotations only'],
|
|
626
|
+
conditional: ['Declare the structure with an interface, or describe the body with @ApiResponse({ schema })'],
|
|
627
|
+
mapped: ['Declare the structure with an interface, or describe the body with @ApiResponse({ schema })'],
|
|
628
|
+
'type-parameter': ['Give the type arguments explicitly'],
|
|
629
|
+
function: ['Remove the member from the transported type'],
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** A JSDoc tag with an invalid value. */
|
|
633
|
+
interface TagIssue {
|
|
634
|
+
readonly message: string
|
|
635
|
+
readonly location: Location
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const NUMERIC_TAGS = new Set(['minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf'])
|
|
639
|
+
const COUNT_TAGS = new Set(['minLength', 'maxLength', 'minItems', 'maxItems'])
|
|
640
|
+
|
|
641
|
+
/** Adds the JSDoc text and tags of `doc` to a schema (§6.2 "JSDoc tags on types and properties"). */
|
|
642
|
+
export function withDoc(
|
|
643
|
+
schema: JsonSchema,
|
|
644
|
+
doc: DocComment | undefined,
|
|
645
|
+
report: (issue: TagIssue) => void,
|
|
646
|
+
): JsonSchema {
|
|
647
|
+
if (doc === undefined) return schema
|
|
648
|
+
const result: Record<string, JsonValue | undefined> = { ...schema }
|
|
649
|
+
if (doc.text !== '') result['description'] = doc.text
|
|
650
|
+
for (const tag of doc.tags) {
|
|
651
|
+
const text = tag.text.trim()
|
|
652
|
+
const invalid = (expected: string): void =>
|
|
653
|
+
report({
|
|
654
|
+
message: `Invalid @${tag.name} value ${JSON.stringify(text)}: expected ${expected}`,
|
|
655
|
+
location: tag.location,
|
|
656
|
+
})
|
|
657
|
+
if (tag.name === 'deprecated') {
|
|
658
|
+
result['deprecated'] = true
|
|
659
|
+
} else if (tag.name === 'example') {
|
|
660
|
+
const examples = Array.isArray(result['examples']) ? (result['examples'] as JsonValue[]) : []
|
|
661
|
+
result['examples'] = [...examples, parseTagValue(stripFence(text))]
|
|
662
|
+
} else if (tag.name === 'default') {
|
|
663
|
+
result['default'] = parseTagValue(text)
|
|
664
|
+
} else if (tag.name === 'format') {
|
|
665
|
+
if (text === '' || /\s/.test(text)) invalid('a format name (date, email, int32...)')
|
|
666
|
+
else {
|
|
667
|
+
result['format'] = text
|
|
668
|
+
if ((text === 'int32' || text === 'int64') && result['type'] === 'number') result['type'] = 'integer'
|
|
669
|
+
}
|
|
670
|
+
} else if (NUMERIC_TAGS.has(tag.name)) {
|
|
671
|
+
const value = parseTagValue(text)
|
|
672
|
+
if (typeof value === 'number' && Number.isFinite(value)) result[tag.name] = value
|
|
673
|
+
else invalid('a number')
|
|
674
|
+
} else if (COUNT_TAGS.has(tag.name)) {
|
|
675
|
+
const value = parseTagValue(text)
|
|
676
|
+
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) result[tag.name] = value
|
|
677
|
+
else invalid('a non-negative integer')
|
|
678
|
+
} else if (tag.name === 'pattern') {
|
|
679
|
+
try {
|
|
680
|
+
new RegExp(text, 'u')
|
|
681
|
+
result['pattern'] = text
|
|
682
|
+
} catch {
|
|
683
|
+
invalid('a regular expression')
|
|
684
|
+
}
|
|
685
|
+
} else if (tag.name === 'uniqueItems') {
|
|
686
|
+
const value = text === '' ? true : parseTagValue(text)
|
|
687
|
+
if (typeof value === 'boolean') result['uniqueItems'] = value
|
|
688
|
+
else invalid('true or false')
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
return result as JsonSchema
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** A tag value: JSON when it parses, the text otherwise. */
|
|
695
|
+
export function parseTagValue(text: string): JsonValue {
|
|
696
|
+
try {
|
|
697
|
+
return JSON.parse(text) as JsonValue
|
|
698
|
+
} catch {
|
|
699
|
+
return text
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function stripFence(text: string): string {
|
|
704
|
+
const fenced = /^```[\w-]*\n([\s\S]*?)\n```$/.exec(text)
|
|
705
|
+
return fenced?.[1]?.trim() ?? text
|
|
706
|
+
}
|