@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specbook/codegen",
3
- "version": "0.8.1",
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.8.1",
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
- * The response example the spec gives, when it has one. The one `example` is shown as written; a named one only
278
- * when it satisfies the response schema, since specs' own examples often leave out a required field.
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 specExample = (media: any, schema: Schema, status: number): unknown => {
281
- if (media?.example !== undefined) return sanitizeExample(media.example, '', undefined, status)
282
- const named = firstExample(media?.examples)
283
- if (named === undefined) return undefined
284
- const value = sanitizeExample(named, '', undefined, status)
285
- return conforms(schema, value, resolve) ? value : undefined
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
- specExample(found.media, found.schema, -1) ??
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
- specExample(found.media, found.schema, Number(code)) ??
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
- // the viewer checks a value against the first branch of its own kind, so that is the branch it has to fit
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
- if (!conforms(alts.find(same) ?? alts[0], value, resolve, d + 1)) return false
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
- if (seen.includes(schema.$ref)) return again(target, key, Math.max(depth, MAX_DEPTH), [])
482
- return again(target, key, depth, [...seen, schema.$ref])
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 out = again(describedAs(pick, schema))
506
- const prop = schema.discriminator?.propertyName
507
- if (prop && pick.$ref && out && typeof out === 'object' && !Array.isArray(out)) {
508
- const mapped = Object.entries<string>(schema.discriminator.mapping ?? {}).find(
509
- ([, ref]) => ref === pick.$ref,
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
+ }