@specbook/codegen 0.8.1 → 0.9.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/package.json +6 -2
- package/src/build.ts +133 -16
- package/src/hints.ts +8 -2
- package/src/schema.ts +113 -14
- package/src/union.ts +175 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@specbook/codegen",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Turns an OpenAPI or Swagger document into types, examples, schemas and metadata, in Node or in a browser.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"specbook",
|
|
@@ -32,6 +32,10 @@
|
|
|
32
32
|
"./browser": {
|
|
33
33
|
"types": "./src/browser.ts",
|
|
34
34
|
"default": "./src/browser.ts"
|
|
35
|
+
},
|
|
36
|
+
"./union": {
|
|
37
|
+
"types": "./src/union.ts",
|
|
38
|
+
"default": "./src/union.ts"
|
|
35
39
|
}
|
|
36
40
|
},
|
|
37
41
|
"dependencies": {
|
|
@@ -40,7 +44,7 @@
|
|
|
40
44
|
"swagger2openapi": "7.0.8"
|
|
41
45
|
},
|
|
42
46
|
"devDependencies": {
|
|
43
|
-
"@specbook/core": "0.
|
|
47
|
+
"@specbook/core": "0.9.0",
|
|
44
48
|
"@types/node": "24.19.2",
|
|
45
49
|
"@types/swagger2openapi": "7.0.4",
|
|
46
50
|
"typescript": "7.0.2"
|
package/src/build.ts
CHANGED
|
@@ -4,13 +4,16 @@ import {
|
|
|
4
4
|
jsdoc,
|
|
5
5
|
objectType,
|
|
6
6
|
pascal,
|
|
7
|
+
propKey,
|
|
7
8
|
type Schema,
|
|
8
9
|
safeIdent,
|
|
9
10
|
sanitizeExample,
|
|
10
11
|
synthesize,
|
|
12
|
+
synthesizeVariant,
|
|
11
13
|
toJsonSchema,
|
|
12
14
|
tsType,
|
|
13
15
|
} from './schema'
|
|
16
|
+
import { flatProps, nameOf, pinnedValue, subtypeTag, unionOf } from './union'
|
|
14
17
|
|
|
15
18
|
export interface GenerateOptions {
|
|
16
19
|
/** Path/URL to the spec, or an already-parsed object. */
|
|
@@ -78,6 +81,11 @@ export interface ManifestOperation {
|
|
|
78
81
|
description?: string
|
|
79
82
|
tags: string[]
|
|
80
83
|
statuses: number[]
|
|
84
|
+
/**
|
|
85
|
+
* The answers a status's example file holds, when there are several: a `oneOf` response (one per branch) or a
|
|
86
|
+
* response with named `examples`. Keyed by status; the file is `{ "$variants": [...] }` (see core's `mock-variants`).
|
|
87
|
+
*/
|
|
88
|
+
mockVariants?: Record<string, { name: string; title?: string; tag?: string }[]>
|
|
81
89
|
examples: ManifestExample[]
|
|
82
90
|
/** the spec requires credentials for this operation */
|
|
83
91
|
secured?: boolean
|
|
@@ -110,6 +118,7 @@ const BODY_ENCODINGS: [string, string][] = [
|
|
|
110
118
|
['text/plain', 'text'],
|
|
111
119
|
]
|
|
112
120
|
const BINARY_MIME = /^(application\/(octet-stream|pdf|zip|gzip)|(image|audio|video)\/)/
|
|
121
|
+
const WILDCARD_MIME = /^(\*|application)\/\*$/
|
|
113
122
|
|
|
114
123
|
function findJsonLike(content: Record<string, any> | undefined) {
|
|
115
124
|
if (!content) return undefined
|
|
@@ -136,6 +145,19 @@ function findJsonLike(content: Record<string, any> | undefined) {
|
|
|
136
145
|
media: content[binary],
|
|
137
146
|
mediaType: binary,
|
|
138
147
|
}
|
|
148
|
+
// A wildcard stands for whatever the server sends, and a framework that is not told what it produces
|
|
149
|
+
// declares every response that way (springdoc's `*/*`). It is JSON, unless the schema is a file.
|
|
150
|
+
const any = Object.keys(content).find((m) => WILDCARD_MIME.test(m))
|
|
151
|
+
if (any) {
|
|
152
|
+
const schema = content[any].schema as Schema | undefined
|
|
153
|
+
const file = schema?.format === 'binary'
|
|
154
|
+
return {
|
|
155
|
+
schema,
|
|
156
|
+
encoding: file ? 'binary' : 'json',
|
|
157
|
+
media: content[any],
|
|
158
|
+
...(file ? { mediaType: any } : {}),
|
|
159
|
+
}
|
|
160
|
+
}
|
|
139
161
|
return undefined
|
|
140
162
|
}
|
|
141
163
|
|
|
@@ -274,20 +296,97 @@ function generateAll(
|
|
|
274
296
|
// what the spec says about a field elsewhere in the document; read on first use
|
|
275
297
|
const index = makeValueIndex(doc)
|
|
276
298
|
/**
|
|
277
|
-
*
|
|
278
|
-
*
|
|
299
|
+
* What the example file of a response holds: a bare body, or when the spec gives several answers for the status, a
|
|
300
|
+
* `{ "$variants": [...] }` envelope that the mock reads (core's `mock-variants`). Several named `examples` are those
|
|
301
|
+
* answers; a union response has one per branch (found on the document's own schema, not the one written for the
|
|
302
|
+
* browser, where `nullable` wraps a reference in an `anyOf`).
|
|
279
303
|
*/
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
304
|
+
const responseExample = (
|
|
305
|
+
media: any,
|
|
306
|
+
schema: Schema,
|
|
307
|
+
status: number,
|
|
308
|
+
): { file: unknown; variants?: { name: string; title?: string; tag?: string }[] } => {
|
|
309
|
+
const used = new Set<string>()
|
|
310
|
+
const unique = (name: string) => {
|
|
311
|
+
let n = name
|
|
312
|
+
while (used.has(n)) n += '-2'
|
|
313
|
+
used.add(n)
|
|
314
|
+
return n
|
|
315
|
+
}
|
|
316
|
+
const wrap = (list: { name: string; title?: string; tag?: string; body: unknown }[]) =>
|
|
317
|
+
list.length < 2
|
|
318
|
+
? undefined
|
|
319
|
+
: {
|
|
320
|
+
file: { $variants: list },
|
|
321
|
+
variants: list.map(({ body: _b, ...rest }) => rest),
|
|
322
|
+
}
|
|
323
|
+
if (media?.example !== undefined)
|
|
324
|
+
return { file: sanitizeExample(media.example, '', undefined, status) }
|
|
325
|
+
const fitting: { name: string; title?: string; body: unknown }[] = []
|
|
326
|
+
for (const [key, raw] of Object.entries<any>(media?.examples ?? {})) {
|
|
327
|
+
const ex = resolveAny<any>(raw)
|
|
328
|
+
if (ex?.value === undefined) continue
|
|
329
|
+
const body = sanitizeExample(ex.value, '', undefined, status)
|
|
330
|
+
if (!conforms(schema, body, resolve)) continue
|
|
331
|
+
fitting.push({
|
|
332
|
+
name: unique(key),
|
|
333
|
+
...(typeof ex.summary === 'string' ? { title: ex.summary } : {}),
|
|
334
|
+
body,
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
const several = wrap(fitting)
|
|
338
|
+
if (several) return several
|
|
339
|
+
const root = schema.$ref ? resolve(schema.$ref) : schema
|
|
340
|
+
const union = unionOf(root, { resolve })
|
|
341
|
+
if (union && union.branches.length >= 2 && root) {
|
|
342
|
+
used.clear()
|
|
343
|
+
const list = union.branches.map((b, i) => {
|
|
344
|
+
const target = b.schema.$ref ? resolve(b.schema.$ref) : b.schema
|
|
345
|
+
const title = typeof target?.title === 'string' ? target.title : undefined
|
|
346
|
+
return {
|
|
347
|
+
name: unique(b.name ?? b.tag ?? `variant-${i + 1}`),
|
|
348
|
+
...(title && title !== b.name ? { title } : {}),
|
|
349
|
+
...(b.tag !== undefined ? { tag: b.tag } : {}),
|
|
350
|
+
body: synthesizeVariant(root, union, i, resolve, status, false, index),
|
|
351
|
+
}
|
|
352
|
+
})
|
|
353
|
+
// the spec's one example stands for the variant it names, not for all of them
|
|
354
|
+
const only = fitting[0]
|
|
355
|
+
if (only) {
|
|
356
|
+
const at = union.tagProp
|
|
357
|
+
? list.findIndex(
|
|
358
|
+
(v) =>
|
|
359
|
+
v.tag !== undefined &&
|
|
360
|
+
(only.body as Record<string, unknown>)?.[union.tagProp as string] === v.tag,
|
|
361
|
+
)
|
|
362
|
+
: -1
|
|
363
|
+
const into = list[at >= 0 ? at : 0]
|
|
364
|
+
if (into) into.body = only.body
|
|
365
|
+
}
|
|
366
|
+
return wrap(list) as ReturnType<typeof responseExample>
|
|
367
|
+
}
|
|
368
|
+
if (fitting[0]) return { file: fitting[0].body }
|
|
369
|
+
return { file: synthesize(schema, resolve, 0, [], '', status, false, index) }
|
|
286
370
|
}
|
|
287
371
|
|
|
288
372
|
const files: GeneratedFile[] = []
|
|
289
373
|
const operations: ManifestOperation[] = []
|
|
290
374
|
|
|
375
|
+
// The value that tells a variant apart is its type, so a flow can narrow on it (`item.kind === 'album'`): a base
|
|
376
|
+
// holds the `discriminator` and the variants only extend it, and a union can hold its own.
|
|
377
|
+
const tags = new Map<string, { prop: string; value: string }>()
|
|
378
|
+
if (ts) {
|
|
379
|
+
const ctx = { resolve }
|
|
380
|
+
for (const [name, schema] of Object.entries(schemas)) {
|
|
381
|
+
const own = subtypeTag(`#/components/schemas/${name}`, ctx)
|
|
382
|
+
if (own) tags.set(pascal(name), own)
|
|
383
|
+
const union = unionOf(schema, ctx)
|
|
384
|
+
if (union?.tagSource === 'discriminator' && union.tagProp)
|
|
385
|
+
for (const b of union.branches)
|
|
386
|
+
if (b.schema.$ref && b.tag !== undefined)
|
|
387
|
+
tags.set(pascal(nameOf(b.schema.$ref)), { prop: union.tagProp, value: b.tag })
|
|
388
|
+
}
|
|
389
|
+
}
|
|
291
390
|
const typeBlocks: string[] = []
|
|
292
391
|
for (const [name, schema] of ts ? Object.entries(schemas) : []) {
|
|
293
392
|
const id = pascal(name)
|
|
@@ -300,6 +399,18 @@ function generateAll(
|
|
|
300
399
|
(schema.type === 'object' || (!schema.type && schema.properties)) &&
|
|
301
400
|
schema.properties
|
|
302
401
|
const body = isObj ? objectType(schema) : undefined
|
|
402
|
+
const tag = tags.get(id)
|
|
403
|
+
// a variant that already pins the property itself (an `enum` of one value) says it
|
|
404
|
+
const pinned =
|
|
405
|
+
tag && pinnedValue(flatProps({ $ref: `#/components/schemas/${name}` }, { resolve })[tag.prop])
|
|
406
|
+
if (tag && pinned === undefined) {
|
|
407
|
+
const base = body ?? tsType(schema)
|
|
408
|
+
const whole = schema.oneOf || schema.anyOf ? `(${base})` : base
|
|
409
|
+
typeBlocks.push(
|
|
410
|
+
`${jsdoc(schema)}export type ${id} = ${whole} & { ${propKey(tag.prop)}: ${JSON.stringify(tag.value)} }`,
|
|
411
|
+
)
|
|
412
|
+
continue
|
|
413
|
+
}
|
|
303
414
|
typeBlocks.push(
|
|
304
415
|
body?.startsWith('{')
|
|
305
416
|
? `${jsdoc(schema)}export interface ${id} ${body}`
|
|
@@ -385,6 +496,12 @@ function generateAll(
|
|
|
385
496
|
let hasDefault = false
|
|
386
497
|
let defaultSchema: Schema | undefined
|
|
387
498
|
const responseSchemas: Record<string, Schema> = {}
|
|
499
|
+
const mockVariants: NonNullable<ManifestOperation['mockVariants']> = {}
|
|
500
|
+
let defaultVariants: ManifestOperation['mockVariants'] extends infer M
|
|
501
|
+
? M extends Record<string, infer V>
|
|
502
|
+
? V | undefined
|
|
503
|
+
: never
|
|
504
|
+
: never
|
|
388
505
|
for (const [code, rawRes] of Object.entries<any>(op.responses ?? {})) {
|
|
389
506
|
if (code === 'default') {
|
|
390
507
|
const found = findJsonLike(resolveAny<any>(rawRes)?.content)
|
|
@@ -392,12 +509,11 @@ function generateAll(
|
|
|
392
509
|
hasDefault = true
|
|
393
510
|
if (ts) defaultType = tsType(found.schema, ' ', 'T.')
|
|
394
511
|
defaultSchema = toJsonSchema(found.schema, `${defsId}#/$defs/`)
|
|
395
|
-
const example =
|
|
396
|
-
|
|
397
|
-
synthesize(found.schema, resolve, 0, [], '', -1, false, index)
|
|
512
|
+
const example = responseExample(found.media, found.schema, -1)
|
|
513
|
+
defaultVariants = example.variants
|
|
398
514
|
exampleFiles.push({
|
|
399
515
|
path: `examples/${id}/default.response.json`,
|
|
400
|
-
content: `${JSON.stringify(example, null, 2)}\n`,
|
|
516
|
+
content: `${JSON.stringify(example.file, null, 2)}\n`,
|
|
401
517
|
})
|
|
402
518
|
}
|
|
403
519
|
continue
|
|
@@ -412,12 +528,11 @@ function generateAll(
|
|
|
412
528
|
`${code}: ${found?.schema ? tsType(found.schema, ' ', 'T.') : 'unknown'}`,
|
|
413
529
|
)
|
|
414
530
|
if (found?.schema) {
|
|
415
|
-
const example =
|
|
416
|
-
|
|
417
|
-
synthesize(found.schema, resolve, 0, [], '', Number(code), false, index)
|
|
531
|
+
const example = responseExample(found.media, found.schema, Number(code))
|
|
532
|
+
if (example.variants) mockVariants[code] = example.variants
|
|
418
533
|
exampleFiles.push({
|
|
419
534
|
path: `examples/${id}/${code}.response.json`,
|
|
420
|
-
content: `${JSON.stringify(example, null, 2)}\n`,
|
|
535
|
+
content: `${JSON.stringify(example.file, null, 2)}\n`,
|
|
421
536
|
})
|
|
422
537
|
}
|
|
423
538
|
}
|
|
@@ -426,6 +541,7 @@ function generateAll(
|
|
|
426
541
|
if (statuses.includes(st)) continue
|
|
427
542
|
statuses.push(st)
|
|
428
543
|
if (defaultSchema) responseSchemas[String(st)] = defaultSchema
|
|
544
|
+
if (defaultVariants) mockVariants[String(st)] = defaultVariants
|
|
429
545
|
if (defaultType) responseTypes.push(`${st}: ${defaultType}`)
|
|
430
546
|
}
|
|
431
547
|
}
|
|
@@ -590,6 +706,7 @@ ${jsdoc({ description: [...new Set([op.summary, op.description].filter(Boolean))
|
|
|
590
706
|
...(op.description && op.description !== summary ? { description: op.description } : {}),
|
|
591
707
|
tags,
|
|
592
708
|
statuses,
|
|
709
|
+
...(Object.keys(mockVariants).length ? { mockVariants } : {}),
|
|
593
710
|
examples: docs.map((d) => ({ name: d.name, title: d.title, source: d.source })),
|
|
594
711
|
...(secured ? { secured: true } : {}),
|
|
595
712
|
file: `operations/${id}.ts`,
|
package/src/hints.ts
CHANGED
|
@@ -120,7 +120,7 @@ export function conforms(
|
|
|
120
120
|
if (value === null) return nullable(schema, resolve)
|
|
121
121
|
if (schema.allOf && !schema.allOf.every((b: Schema) => conforms(b, value, resolve, d + 1)))
|
|
122
122
|
return false
|
|
123
|
-
//
|
|
123
|
+
// a value fits a union when it fits any branch of its own kind (a spec's example for the second variant is as good)
|
|
124
124
|
const alts: Schema[] | undefined = schema.oneOf ?? schema.anyOf
|
|
125
125
|
if (alts?.length) {
|
|
126
126
|
const want = Array.isArray(value) ? 'array' : typeof value
|
|
@@ -128,7 +128,13 @@ export function conforms(
|
|
|
128
128
|
const k = kindOf(b, resolve)
|
|
129
129
|
return k === want || (k === 'integer' && want === 'number')
|
|
130
130
|
}
|
|
131
|
-
|
|
131
|
+
const ofKind = alts.filter(same)
|
|
132
|
+
if (
|
|
133
|
+
!(ofKind.length ? ofKind : [alts[0] as Schema]).some((b) =>
|
|
134
|
+
conforms(b, value, resolve, d + 1),
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
return false
|
|
132
138
|
}
|
|
133
139
|
if (!fits(schema, value)) return false
|
|
134
140
|
if (Array.isArray(value))
|
package/src/schema.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
PLACEHOLDER_VALUE,
|
|
9
9
|
type ValueIndex,
|
|
10
10
|
} from './hints'
|
|
11
|
+
import { flatProps, subtypeTag, type UnionContext, type UnionInfo, unionOf } from './union'
|
|
11
12
|
|
|
12
13
|
export type Schema = Record<string, any>
|
|
13
14
|
|
|
@@ -61,6 +62,8 @@ export function tsType(schema: Schema | undefined, indent = '', p = ''): string
|
|
|
61
62
|
let base: string
|
|
62
63
|
if (schema.enum) {
|
|
63
64
|
base = schema.enum.map((v: unknown) => JSON.stringify(v)).join(' | ') || 'never'
|
|
65
|
+
} else if (schema.const !== undefined) {
|
|
66
|
+
base = JSON.stringify(schema.const)
|
|
64
67
|
} else if (schema.allOf) {
|
|
65
68
|
base = schema.allOf.map((s: Schema) => paren(tsType(s, indent, p))).join(' & ')
|
|
66
69
|
} else if (schema.oneOf || schema.anyOf) {
|
|
@@ -456,6 +459,63 @@ export function sanitizeExample(
|
|
|
456
459
|
/** A hard stop for chains of *required* objects, which is all an example follows past MAX_DEPTH. */
|
|
457
460
|
const HARD_DEPTH = 14
|
|
458
461
|
|
|
462
|
+
/** How many items of a list of variants a success response shows: one of each kind, up to this many. */
|
|
463
|
+
const FANOUT = 3
|
|
464
|
+
|
|
465
|
+
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
|
466
|
+
!!v && typeof v === 'object' && !Array.isArray(v)
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Writes the value that tells a variant apart (`kind: "album"`) into its example. The tag is the document's
|
|
470
|
+
* `discriminator` value, not the name heuristic's guess; a property declared with other values keeps what it has.
|
|
471
|
+
*/
|
|
472
|
+
function applyTag(
|
|
473
|
+
out: unknown,
|
|
474
|
+
prop: string,
|
|
475
|
+
value: string,
|
|
476
|
+
ref: string,
|
|
477
|
+
ctx: UnionContext,
|
|
478
|
+
): unknown {
|
|
479
|
+
if (!isPlainObject(out)) return out
|
|
480
|
+
const declared = flatProps(ctx.resolve(ref), ctx)[prop]
|
|
481
|
+
if (declared?.enum && !declared.enum.includes(value)) return out
|
|
482
|
+
if (declared?.const !== undefined && declared.const !== value) return out
|
|
483
|
+
out[prop] = value
|
|
484
|
+
return out
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* The example of one alternative of a union (`u.branches[i]`), tagged with the value that tells it apart from the
|
|
489
|
+
* others when the union says how. `synthesize` shows the first; a response with several variants writes each.
|
|
490
|
+
*/
|
|
491
|
+
export function synthesizeVariant(
|
|
492
|
+
union: Schema,
|
|
493
|
+
u: UnionInfo,
|
|
494
|
+
i: number,
|
|
495
|
+
resolve: (ref: string) => Schema | undefined,
|
|
496
|
+
status?: number,
|
|
497
|
+
sparse = false,
|
|
498
|
+
index?: ValueIndex,
|
|
499
|
+
depth = 0,
|
|
500
|
+
seen: string[] = [],
|
|
501
|
+
key = '',
|
|
502
|
+
): unknown {
|
|
503
|
+
const b = u.branches[i] as UnionInfo['branches'][number]
|
|
504
|
+
const out = synthesize(
|
|
505
|
+
describedAs(b.schema, union),
|
|
506
|
+
resolve,
|
|
507
|
+
depth,
|
|
508
|
+
seen,
|
|
509
|
+
key,
|
|
510
|
+
status,
|
|
511
|
+
sparse,
|
|
512
|
+
index,
|
|
513
|
+
)
|
|
514
|
+
return u.tagSource === 'discriminator' && u.tagProp && b.tag !== undefined && b.schema.$ref
|
|
515
|
+
? applyTag(out, u.tagProp, b.tag, b.schema.$ref, { resolve })
|
|
516
|
+
: out
|
|
517
|
+
}
|
|
518
|
+
|
|
459
519
|
/**
|
|
460
520
|
* Deterministic, human-readable example: example > default > enum[0] > heuristics by name/format.
|
|
461
521
|
* With `fake`, free-form string leaves become `{{faker.*}}` placeholders so every play is fresh.
|
|
@@ -475,12 +535,20 @@ export function synthesize(
|
|
|
475
535
|
if (!schema) return null
|
|
476
536
|
const again = (s: Schema | undefined, k = key, d = depth, sn = seen) =>
|
|
477
537
|
synthesize(s, resolve, d, sn, k, status, sparse, index)
|
|
538
|
+
const ctx: UnionContext = { resolve }
|
|
478
539
|
if (schema.$ref) {
|
|
479
540
|
const target = resolve(schema.$ref)
|
|
480
541
|
// a reference back into itself carries on as "required fields only", which the depth rules then end
|
|
481
|
-
|
|
482
|
-
|
|
542
|
+
const out = seen.includes(schema.$ref)
|
|
543
|
+
? again(target, key, Math.max(depth, MAX_DEPTH), [])
|
|
544
|
+
: again(target, key, depth, [...seen, schema.$ref])
|
|
545
|
+
// a variant of a family whose base holds the discriminator says which one it is
|
|
546
|
+
const tag = subtypeTag(schema.$ref, ctx)
|
|
547
|
+
return tag ? applyTag(out, tag.prop, tag.value, schema.$ref, ctx) : out
|
|
483
548
|
}
|
|
549
|
+
/** One alternative of a union, tagged when the union says how its alternatives are told apart. */
|
|
550
|
+
const variant = (union: Schema, u: UnionInfo, i: number, k: string, d: number, sn: string[]) =>
|
|
551
|
+
synthesizeVariant(union, u, i, resolve, status, sparse, index, d, sn, k)
|
|
484
552
|
// a spec example that contradicts its own schema (a string field whose example is an array) is ignored
|
|
485
553
|
const given = exampleOf(schema)
|
|
486
554
|
if (given !== undefined && fits(schema, given))
|
|
@@ -502,18 +570,11 @@ export function synthesize(
|
|
|
502
570
|
// OpenAPI 3.1 writes an optional field as `anyOf: [{ type: 'null' }, ...]`: the first real branch shows the field
|
|
503
571
|
const pick = branches?.find((b) => b.type !== 'null') ?? branches?.[0]
|
|
504
572
|
if (pick) {
|
|
505
|
-
const
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
)?.[0]
|
|
511
|
-
const value = mapped ?? (pick.$ref as string).split('/').pop()
|
|
512
|
-
const declared = resolve(pick.$ref)?.properties?.[prop]
|
|
513
|
-
if (!declared?.enum || declared.enum.includes(value))
|
|
514
|
-
(out as Record<string, unknown>)[prop] = value
|
|
515
|
-
}
|
|
516
|
-
return out
|
|
573
|
+
const u = unionOf(schema, ctx)
|
|
574
|
+
const at = u?.branches.findIndex((b) => b.schema === pick) ?? -1
|
|
575
|
+
return u && at >= 0
|
|
576
|
+
? variant(schema, u, at, key, depth, seen)
|
|
577
|
+
: again(describedAs(pick, schema))
|
|
517
578
|
}
|
|
518
579
|
const type = Array.isArray(schema.type)
|
|
519
580
|
? schema.type.find((t: string) => t !== 'null')
|
|
@@ -529,6 +590,30 @@ export function synthesize(
|
|
|
529
590
|
return exampleBoolean(key)
|
|
530
591
|
case 'array': {
|
|
531
592
|
if (depth >= MAX_DEPTH || schema.maxItems === 0) return []
|
|
593
|
+
// a success response lists one of each kind when its items are a union (a request keeps one to edit)
|
|
594
|
+
const items = schema.items as Schema | undefined
|
|
595
|
+
const kinds =
|
|
596
|
+
status !== undefined &&
|
|
597
|
+
status >= 200 &&
|
|
598
|
+
status < 400 &&
|
|
599
|
+
!(items?.$ref && seen.includes(items.$ref))
|
|
600
|
+
? unionOf(items?.$ref ? resolve(items.$ref) : items, ctx)
|
|
601
|
+
: undefined
|
|
602
|
+
if (kinds) {
|
|
603
|
+
const into = items?.$ref ? [...seen, items.$ref] : seen
|
|
604
|
+
const union = (items?.$ref ? resolve(items.$ref) : items) as Schema
|
|
605
|
+
const count = Math.max(
|
|
606
|
+
1,
|
|
607
|
+
Math.min(
|
|
608
|
+
Math.max(Math.min(kinds.branches.length, FANOUT), schema.minItems ?? 0),
|
|
609
|
+
schema.maxItems ?? 5,
|
|
610
|
+
5,
|
|
611
|
+
),
|
|
612
|
+
)
|
|
613
|
+
return Array.from({ length: count }, (_, i) =>
|
|
614
|
+
variant(union, kinds, i % kinds.branches.length, singular(key), depth + 1, into),
|
|
615
|
+
)
|
|
616
|
+
}
|
|
532
617
|
// as many items as the schema asks for, distinct when it says they must be
|
|
533
618
|
const first = again(schema.items, singular(key), depth + 1)
|
|
534
619
|
const count = Math.min(Math.max(1, schema.minItems ?? 1), 5)
|
|
@@ -671,6 +756,20 @@ function convertSchema(schema: Schema | undefined, refPrefix: string): Schema {
|
|
|
671
756
|
if (schema.deprecated) meta.deprecated = true
|
|
672
757
|
if (schema.default !== undefined) meta.default = schema.default
|
|
673
758
|
if (schema.example !== undefined) meta.examples = [schema.example]
|
|
759
|
+
// which branch a value is: kept, with its targets as the definition names the browser resolves
|
|
760
|
+
if (typeof schema.discriminator?.propertyName === 'string')
|
|
761
|
+
meta.discriminator = {
|
|
762
|
+
propertyName: schema.discriminator.propertyName,
|
|
763
|
+
...(schema.discriminator.mapping
|
|
764
|
+
? {
|
|
765
|
+
mapping: Object.fromEntries(
|
|
766
|
+
Object.entries<unknown>(schema.discriminator.mapping)
|
|
767
|
+
.filter(([, target]) => typeof target === 'string')
|
|
768
|
+
.map(([key, target]) => [key, refName(target as string)]),
|
|
769
|
+
),
|
|
770
|
+
}
|
|
771
|
+
: {}),
|
|
772
|
+
}
|
|
674
773
|
|
|
675
774
|
const rec = (s: Schema) => toJsonSchema(s, refPrefix)
|
|
676
775
|
const withPlaceholder = (body: Schema): Schema => ({ ...meta, anyOf: [body, PLACEHOLDER] })
|
package/src/union.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Polymorphism: what a `oneOf`/`anyOf` is made of and how its branches are told apart. Pure and free of
|
|
3
|
+
* imports, so the example generator (raw OpenAPI, `#/components/schemas/X`) and the browser UI (generated
|
|
4
|
+
* definitions, `…#/$defs/X`) share one answer. `$ref`s are compared by their last path segment.
|
|
5
|
+
*
|
|
6
|
+
* Two ways a family says which branch a value is, both common in Spring/Jackson documents:
|
|
7
|
+
* - a `discriminator`, on the union itself or on a base the branches extend through `allOf`
|
|
8
|
+
* (`mapping` keys are the values; a branch with no entry is named by its schema);
|
|
9
|
+
* - a property every branch pins to its own single value with `enum`/`const` (`error: ["OUT_OF_STOCK"]`).
|
|
10
|
+
*/
|
|
11
|
+
export type Schema = Record<string, any>
|
|
12
|
+
export type Resolve = (ref: string) => Schema | undefined
|
|
13
|
+
|
|
14
|
+
export interface UnionContext {
|
|
15
|
+
resolve: Resolve
|
|
16
|
+
/** a branch that is not a real alternative (the UI's `"{{variable}}"` placeholder) */
|
|
17
|
+
ignore?: (branch: Schema) => boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface UnionBranch {
|
|
21
|
+
schema: Schema
|
|
22
|
+
/** the schema's name when the branch is a `$ref`, else its `title` */
|
|
23
|
+
name?: string
|
|
24
|
+
/** the value of the union's tag property that selects this branch */
|
|
25
|
+
tag?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface UnionInfo {
|
|
29
|
+
/** the real alternatives, in the document's order (`null` and placeholder branches left out) */
|
|
30
|
+
branches: UnionBranch[]
|
|
31
|
+
tagProp?: string
|
|
32
|
+
tagSource?: 'discriminator' | 'enum'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The name a `$ref` ends in. */
|
|
36
|
+
export const nameOf = (ref: string): string => ref.slice(ref.lastIndexOf('/') + 1)
|
|
37
|
+
|
|
38
|
+
const DEPTH = 8
|
|
39
|
+
/** Properties that usually tell the variants of a family apart, tried first. */
|
|
40
|
+
const TAG_NAMES = ['type', 'kind', 'error', 'code', 'status', 'title']
|
|
41
|
+
|
|
42
|
+
function mappedTag(discriminator: Schema, name: string): string {
|
|
43
|
+
for (const [key, target] of Object.entries<unknown>(discriminator.mapping ?? {}))
|
|
44
|
+
if (typeof target === 'string' && nameOf(target) === name) return key
|
|
45
|
+
// OpenAPI: a schema with no mapping entry is named by itself
|
|
46
|
+
return name
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The discriminator value a schema gets from the base it extends: `Album` is `allOf [Media, {…}]`, `Media`
|
|
51
|
+
* holds `discriminator { propertyName, mapping }`, so `Album` is `{ prop, value }`. Looks through nested bases.
|
|
52
|
+
*/
|
|
53
|
+
export function subtypeTag(
|
|
54
|
+
ref: string,
|
|
55
|
+
ctx: UnionContext,
|
|
56
|
+
): { prop: string; value: string } | undefined {
|
|
57
|
+
const name = nameOf(ref)
|
|
58
|
+
type Found = { prop: string; value: string } | undefined
|
|
59
|
+
const walk = (schema: Schema | undefined, depth: number, seen: Set<Schema>): Found => {
|
|
60
|
+
if (!schema || depth > DEPTH || seen.has(schema)) return undefined
|
|
61
|
+
seen.add(schema)
|
|
62
|
+
for (const part of (schema.allOf as Schema[] | undefined) ?? []) {
|
|
63
|
+
const base = part.$ref ? ctx.resolve(part.$ref) : part
|
|
64
|
+
if (!base) continue
|
|
65
|
+
if (base.discriminator?.propertyName)
|
|
66
|
+
return {
|
|
67
|
+
prop: base.discriminator.propertyName as string,
|
|
68
|
+
value: mappedTag(base.discriminator, name),
|
|
69
|
+
}
|
|
70
|
+
const deeper = walk(base, depth + 1, seen)
|
|
71
|
+
if (deeper) return deeper
|
|
72
|
+
}
|
|
73
|
+
return undefined
|
|
74
|
+
}
|
|
75
|
+
return walk(ctx.resolve(ref), 0, new Set())
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A schema's properties with its `allOf` parts and bases folded in (the subtype's own win). */
|
|
79
|
+
export function flatProps(
|
|
80
|
+
schema: Schema | undefined,
|
|
81
|
+
ctx: UnionContext,
|
|
82
|
+
depth = 0,
|
|
83
|
+
seen = new Set<Schema>(),
|
|
84
|
+
): Record<string, Schema> {
|
|
85
|
+
if (!schema || depth > DEPTH || seen.has(schema)) return {}
|
|
86
|
+
seen.add(schema)
|
|
87
|
+
const base = schema.$ref ? ctx.resolve(schema.$ref) : schema
|
|
88
|
+
if (!base) return {}
|
|
89
|
+
const out: Record<string, Schema> = {}
|
|
90
|
+
for (const part of (base.allOf as Schema[] | undefined) ?? [])
|
|
91
|
+
Object.assign(out, flatProps(part, ctx, depth + 1, seen))
|
|
92
|
+
return Object.assign(out, base.properties ?? {})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The one value a property is pinned to, if it is pinned. A string enum may arrive as `anyOf [enum, placeholder]`
|
|
97
|
+
* (the browser's definitions let a request hold a `"{{variable}}"` there): the placeholder is not an alternative.
|
|
98
|
+
*/
|
|
99
|
+
export function pinnedValue(prop: Schema | undefined, ctx?: UnionContext): string | undefined {
|
|
100
|
+
let p = prop
|
|
101
|
+
if (Array.isArray(p?.anyOf)) {
|
|
102
|
+
const real = p.anyOf.filter((b: Schema) => b.type !== 'null' && !ctx?.ignore?.(b))
|
|
103
|
+
if (real.length === 1) p = real[0]
|
|
104
|
+
}
|
|
105
|
+
if (!p) return undefined
|
|
106
|
+
const v = p.const !== undefined ? p.const : p.enum?.length === 1 ? p.enum[0] : undefined
|
|
107
|
+
return typeof v === 'string' ? v : undefined
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const memo = new WeakMap<object, UnionInfo | null>()
|
|
111
|
+
|
|
112
|
+
/** The union a schema is, or undefined when it is not one (fewer than two real alternatives is a wrapper, not a union). */
|
|
113
|
+
export function unionOf(schema: Schema | undefined, ctx: UnionContext): UnionInfo | undefined {
|
|
114
|
+
if (!schema || typeof schema !== 'object') return undefined
|
|
115
|
+
const known = memo.get(schema)
|
|
116
|
+
if (known !== undefined) return known ?? undefined
|
|
117
|
+
const info = classify(schema, ctx)
|
|
118
|
+
memo.set(schema, info ?? null)
|
|
119
|
+
return info
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function classify(schema: Schema, ctx: UnionContext): UnionInfo | undefined {
|
|
123
|
+
const raw: Schema[] | undefined = Array.isArray(schema.oneOf)
|
|
124
|
+
? schema.oneOf
|
|
125
|
+
: Array.isArray(schema.anyOf)
|
|
126
|
+
? schema.anyOf
|
|
127
|
+
: undefined
|
|
128
|
+
if (!raw) return undefined
|
|
129
|
+
const real = raw.filter((b) => b && b.type !== 'null' && !ctx.ignore?.(b))
|
|
130
|
+
// fewer than two alternatives is a wrapper (`anyOf [x, null]`), unless the union says how to tell them apart
|
|
131
|
+
if (real.length < 2 && !schema.discriminator?.propertyName) return undefined
|
|
132
|
+
const branches: UnionBranch[] = real.map((b) => ({
|
|
133
|
+
schema: b,
|
|
134
|
+
name: b.$ref ? nameOf(b.$ref) : typeof b.title === 'string' ? b.title : undefined,
|
|
135
|
+
}))
|
|
136
|
+
const done = (tagProp: string, tagSource: 'discriminator' | 'enum'): UnionInfo => ({
|
|
137
|
+
branches,
|
|
138
|
+
tagProp,
|
|
139
|
+
tagSource,
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// 1. a discriminator on the union itself
|
|
143
|
+
if (schema.discriminator?.propertyName) {
|
|
144
|
+
for (const b of branches)
|
|
145
|
+
if (b.name && b.schema.$ref) b.tag = mappedTag(schema.discriminator, b.name)
|
|
146
|
+
return done(schema.discriminator.propertyName, 'discriminator')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 2. a discriminator on a base the branches extend
|
|
150
|
+
if (branches.every((b) => b.schema.$ref)) {
|
|
151
|
+
const found = branches.map((b) => subtypeTag(b.schema.$ref, ctx))
|
|
152
|
+
const prop = found[0]?.prop
|
|
153
|
+
if (prop && found.every((f) => f?.prop === prop)) {
|
|
154
|
+
branches.forEach((b, i) => {
|
|
155
|
+
b.tag = found[i]?.value
|
|
156
|
+
})
|
|
157
|
+
return done(prop, 'discriminator')
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 3. a property each branch pins to its own value
|
|
162
|
+
const props = branches.map((b) => flatProps(b.schema, ctx))
|
|
163
|
+
const first = Object.keys(props[0] ?? {})
|
|
164
|
+
const candidates = [...TAG_NAMES.filter((n) => first.includes(n)), ...first]
|
|
165
|
+
for (const prop of candidates) {
|
|
166
|
+
const values = props.map((p) => pinnedValue(p[prop], ctx))
|
|
167
|
+
if (values.every((v) => v !== undefined) && new Set(values).size === values.length) {
|
|
168
|
+
branches.forEach((b, i) => {
|
|
169
|
+
b.tag = values[i]
|
|
170
|
+
})
|
|
171
|
+
return done(prop, 'enum')
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return { branches }
|
|
175
|
+
}
|