@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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +572 -2
  3. package/dist/build-BTUC96mY.d.mts +174 -0
  4. package/dist/build-DXunOzlI.mjs +733 -0
  5. package/dist/build-DXunOzlI.mjs.map +1 -0
  6. package/dist/compiler.d.mts +16 -0
  7. package/dist/compiler.mjs +2639 -0
  8. package/dist/compiler.mjs.map +1 -0
  9. package/dist/emit-child.d.mts +1 -0
  10. package/dist/emit-child.mjs +124 -0
  11. package/dist/emit-child.mjs.map +1 -0
  12. package/dist/emit.d.mts +2 -0
  13. package/dist/emit.mjs +252 -0
  14. package/dist/emit.mjs.map +1 -0
  15. package/dist/index-CqQ6IRnN.d.mts +60 -0
  16. package/dist/index.d.mts +260 -0
  17. package/dist/index.mjs +777 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/openapi-CQk5dHqc.mjs +39 -0
  20. package/dist/openapi-CQk5dHqc.mjs.map +1 -0
  21. package/dist/shared-BZxtyaB0.mjs +147 -0
  22. package/dist/shared-BZxtyaB0.mjs.map +1 -0
  23. package/dist/testing.d.mts +39 -0
  24. package/dist/testing.mjs +155 -0
  25. package/dist/testing.mjs.map +1 -0
  26. package/dist/types-VzBexMKj.d.mts +213 -0
  27. package/dist/vite.d.mts +24 -0
  28. package/dist/vite.mjs +64 -0
  29. package/dist/vite.mjs.map +1 -0
  30. package/package.json +115 -4
  31. package/src/build.ts +898 -0
  32. package/src/compiler/analyze.ts +81 -0
  33. package/src/compiler/document.ts +612 -0
  34. package/src/compiler/generate.ts +79 -0
  35. package/src/compiler/index.ts +47 -0
  36. package/src/compiler/model.ts +141 -0
  37. package/src/compiler/operations.ts +1475 -0
  38. package/src/compiler/schemas.ts +706 -0
  39. package/src/compiler/validate.ts +102 -0
  40. package/src/compiler/values.ts +81 -0
  41. package/src/converters.ts +137 -0
  42. package/src/decorators.ts +187 -0
  43. package/src/emit/child.ts +144 -0
  44. package/src/emit/index.ts +377 -0
  45. package/src/emit/protocol.ts +35 -0
  46. package/src/index.ts +94 -0
  47. package/src/metadata.ts +112 -0
  48. package/src/openapi.ts +55 -0
  49. package/src/plugin.ts +810 -0
  50. package/src/shared.ts +154 -0
  51. package/src/testing.ts +224 -0
  52. package/src/types.ts +240 -0
  53. package/src/ui.ts +215 -0
  54. package/src/vite.ts +126 -0
@@ -0,0 +1,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
+ }