@specbook/codegen 0.6.0 → 0.7.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.6.0",
3
+ "version": "0.7.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",
@@ -40,7 +40,7 @@
40
40
  "swagger2openapi": "7.0.8"
41
41
  },
42
42
  "devDependencies": {
43
- "@specbook/core": "0.6.0",
43
+ "@specbook/core": "0.7.0",
44
44
  "@types/node": "24.19.2",
45
45
  "@types/swagger2openapi": "7.0.4",
46
46
  "typescript": "7.0.2"
package/src/build.ts CHANGED
@@ -1,4 +1,6 @@
1
+ import { conforms, makeValueIndex } from './hints'
1
2
  import {
3
+ clearExampleCaches,
2
4
  jsdoc,
3
5
  objectType,
4
6
  pascal,
@@ -233,6 +235,22 @@ export function generateFromDoc(
233
235
  files: GeneratedFile[]
234
236
  operations: ManifestOperation[]
235
237
  metas: Record<string, OperationMeta>
238
+ } {
239
+ try {
240
+ return generateAll(doc, opts, flags)
241
+ } finally {
242
+ clearExampleCaches()
243
+ }
244
+ }
245
+
246
+ function generateAll(
247
+ doc: any,
248
+ opts: Omit<GenerateOptions, 'spec'>,
249
+ flags: GenerateFlags,
250
+ ): {
251
+ files: GeneratedFile[]
252
+ operations: ManifestOperation[]
253
+ metas: Record<string, OperationMeta>
236
254
  } {
237
255
  const core = opts.corePackage ?? '@specbook/core'
238
256
  const ts = !flags.dataOnly
@@ -253,6 +271,20 @@ export function generateFromDoc(
253
271
  const firstExample = (examples: Record<string, any> | undefined) =>
254
272
  resolveAny<any>(Object.values(examples ?? {})[0])?.value
255
273
 
274
+ // what the spec says about a field elsewhere in the document; read on first use
275
+ const index = makeValueIndex(doc)
276
+ /**
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.
279
+ */
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
286
+ }
287
+
256
288
  const files: GeneratedFile[] = []
257
289
  const operations: ManifestOperation[] = []
258
290
 
@@ -360,9 +392,9 @@ export function generateFromDoc(
360
392
  hasDefault = true
361
393
  if (ts) defaultType = tsType(found.schema, ' ', 'T.')
362
394
  defaultSchema = toJsonSchema(found.schema, `${defsId}#/$defs/`)
363
- const example = found.media?.example
364
- ? sanitizeExample(found.media.example, '', undefined, -1)
365
- : synthesize(found.schema, resolve, 0, [], '', -1)
395
+ const example =
396
+ specExample(found.media, found.schema, -1) ??
397
+ synthesize(found.schema, resolve, 0, [], '', -1, false, index)
366
398
  exampleFiles.push({
367
399
  path: `examples/${id}/default.response.json`,
368
400
  content: `${JSON.stringify(example, null, 2)}\n`,
@@ -380,9 +412,9 @@ export function generateFromDoc(
380
412
  `${code}: ${found?.schema ? tsType(found.schema, ' ', 'T.') : 'unknown'}`,
381
413
  )
382
414
  if (found?.schema) {
383
- const example = found.media?.example
384
- ? sanitizeExample(found.media.example, '', undefined, Number(code))
385
- : synthesize(found.schema, resolve, 0, [], '', Number(code))
415
+ const example =
416
+ specExample(found.media, found.schema, Number(code)) ??
417
+ synthesize(found.schema, resolve, 0, [], '', Number(code), false, index)
386
418
  exampleFiles.push({
387
419
  path: `examples/${id}/${code}.response.json`,
388
420
  content: `${JSON.stringify(example, null, 2)}\n`,
@@ -406,7 +438,8 @@ export function generateFromDoc(
406
438
  const out: Record<string, unknown> = {}
407
439
  for (const p of group(where)) {
408
440
  if (p.example !== undefined) out[p.name] = p.example
409
- else if (p.required) out[p.name] = synthesize(p.schema, resolve, 0, [], p.name)
441
+ else if (p.required)
442
+ out[p.name] = synthesize(p.schema, resolve, 0, [], p.name, undefined, false, index)
410
443
  }
411
444
  return Object.keys(out).length ? out : undefined
412
445
  }
@@ -447,7 +480,7 @@ export function generateFromDoc(
447
480
  const plain = {
448
481
  ...paramsFor(),
449
482
  body: body?.schema
450
- ? bodyFor(synthesize(body.schema, resolve, 0, [], '', undefined, true))
483
+ ? bodyFor(synthesize(body.schema, resolve, 0, [], '', undefined, true, index))
451
484
  : undefined,
452
485
  }
453
486
  docs.push({ name: take('default'), title: 'Default', source: 'generated', ...plain })
package/src/hints.ts ADDED
@@ -0,0 +1,408 @@
1
+ // What a spec says about the value of a field, beyond its type: a description that names values, the same field's
2
+ // example elsewhere in the document, a pattern, and the limits a value must fit.
3
+
4
+ type Schema = Record<string, any>
5
+
6
+ /** Spec examples that are just the type name (Swagger editor defaults and the like) carry no information. */
7
+ export const PLACEHOLDER_VALUE = /^(string|str|<string>|\{string\}|string\d+|null)$/i
8
+
9
+ export function clearHintCaches() {
10
+ regexes = new Map()
11
+ made = new Map()
12
+ }
13
+
14
+ // ---------------- limits ----------------
15
+
16
+ let regexes = new Map<string, RegExp | null>()
17
+ function regexOf(pattern: string): RegExp | null {
18
+ let re = regexes.get(pattern)
19
+ if (re === undefined) {
20
+ try {
21
+ re = new RegExp(pattern)
22
+ } catch {
23
+ re = null
24
+ }
25
+ regexes.set(pattern, re)
26
+ }
27
+ return re
28
+ }
29
+
30
+ const FORMATS: Record<string, RegExp> = {
31
+ 'date-time': /^\d{4}-\d\d-\d\dT/,
32
+ date: /^\d{4}-\d\d-\d\d$/,
33
+ email: /^[^@\s]+@[^@\s]+$/,
34
+ uuid: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
35
+ uri: /^[a-z][\w+.-]*:/i,
36
+ url: /^[a-z][\w+.-]*:/i,
37
+ }
38
+
39
+ /** Whether `value` has the JSON type the schema declares and respects its limits (schemas with no plain `type` always fit). */
40
+ export function fits(schema: Schema, value: unknown): boolean {
41
+ if (Array.isArray(schema.enum) && value !== null && !schema.enum.includes(value)) return false
42
+ if (schema.const !== undefined && value !== schema.const) return false
43
+ const type = Array.isArray(schema.type)
44
+ ? schema.type.find((t: string) => t !== 'null')
45
+ : schema.type
46
+ if (value === null) return true
47
+ switch (type) {
48
+ case 'string': {
49
+ if (typeof value !== 'string') return false
50
+ if (schema.maxLength !== undefined && value.length > schema.maxLength) return false
51
+ if (schema.minLength !== undefined && value.length < schema.minLength) return false
52
+ if (schema.pattern && !regexOf(schema.pattern)?.test(value)) return false
53
+ const f = schema.format && FORMATS[schema.format]
54
+ return !f || f.test(value)
55
+ }
56
+ case 'integer':
57
+ case 'number':
58
+ return (
59
+ typeof value === 'number' &&
60
+ (type === 'number' || Number.isInteger(value)) &&
61
+ (schema.minimum === undefined || value >= schema.minimum) &&
62
+ (schema.maximum === undefined || value <= schema.maximum)
63
+ )
64
+ case 'boolean':
65
+ return typeof value === 'boolean'
66
+ case 'array':
67
+ return Array.isArray(value)
68
+ case 'object':
69
+ return typeof value === 'object' && !Array.isArray(value)
70
+ default:
71
+ return true
72
+ }
73
+ }
74
+
75
+ /** Whether a null is allowed here: `nullable`, a `null` type, or a `null` branch. */
76
+ export function nullable(
77
+ schema: Schema | undefined,
78
+ resolve: (ref: string) => Schema | undefined,
79
+ d = 0,
80
+ ): boolean {
81
+ if (!schema || d > 8) return false
82
+ if (schema.$ref) return nullable(resolve(schema.$ref), resolve, d + 1)
83
+ const type = schema.type
84
+ return (
85
+ schema.nullable === true ||
86
+ type === 'null' ||
87
+ (Array.isArray(type) && type.includes('null')) ||
88
+ [...(schema.anyOf ?? []), ...(schema.oneOf ?? [])].some((b: Schema) =>
89
+ nullable(b, resolve, d + 1),
90
+ )
91
+ )
92
+ }
93
+
94
+ /** The JSON kind a schema describes (following refs and taking the first branch of a union). */
95
+ function kindOf(
96
+ s: Schema | undefined,
97
+ resolve: (ref: string) => Schema | undefined,
98
+ d = 0,
99
+ ): string | undefined {
100
+ if (!s || d > 8) return undefined
101
+ if (s.$ref) return kindOf(resolve(s.$ref), resolve, d + 1)
102
+ const first = s.anyOf?.[0] ?? s.oneOf?.[0] ?? s.allOf?.[0]
103
+ if (first) return kindOf(first, resolve, d + 1)
104
+ const type = Array.isArray(s.type) ? s.type.find((t: string) => t !== 'null') : s.type
105
+ return type ?? (s.properties ? 'object' : s.items ? 'array' : undefined)
106
+ }
107
+
108
+ /**
109
+ * Whether a value a spec supplies satisfies its schema: required fields present, types, enums and limits
110
+ * right. A spec example that does not (GitHub's often leave out a required field) is not shown as the answer.
111
+ */
112
+ export function conforms(
113
+ schema: Schema | undefined,
114
+ value: unknown,
115
+ resolve: (ref: string) => Schema | undefined,
116
+ d = 0,
117
+ ): boolean {
118
+ if (!schema || d > 30) return true
119
+ if (schema.$ref) return conforms(resolve(schema.$ref), value, resolve, d + 1)
120
+ if (value === null) return nullable(schema, resolve)
121
+ if (schema.allOf && !schema.allOf.every((b: Schema) => conforms(b, value, resolve, d + 1)))
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
124
+ const alts: Schema[] | undefined = schema.oneOf ?? schema.anyOf
125
+ if (alts?.length) {
126
+ const want = Array.isArray(value) ? 'array' : typeof value
127
+ const same = (b: Schema) => {
128
+ const k = kindOf(b, resolve)
129
+ return k === want || (k === 'integer' && want === 'number')
130
+ }
131
+ if (!conforms(alts.find(same) ?? alts[0], value, resolve, d + 1)) return false
132
+ }
133
+ if (!fits(schema, value)) return false
134
+ if (Array.isArray(value))
135
+ return (
136
+ !schema.items || value.slice(0, 3).every((x) => conforms(schema.items, x, resolve, d + 1))
137
+ )
138
+ if (typeof value === 'object') {
139
+ const have = value as Record<string, unknown>
140
+ if ((schema.required as string[] | undefined)?.some((k) => !(k in have))) return false
141
+ const props = schema.properties as Record<string, Schema> | undefined
142
+ return (
143
+ !props ||
144
+ Object.entries(have).every(([k, v]) => !props[k] || conforms(props[k], v, resolve, d + 1))
145
+ )
146
+ }
147
+ return true
148
+ }
149
+
150
+ // ---------------- descriptions ----------------
151
+
152
+ const LIST =
153
+ /(?:options are|possible values|allowed values|valid values)[^`"]{0,120}?[`"]([^`"\n]{1,80})[`"]/i
154
+ const DIRECT =
155
+ /(?:can be(?: one of)?|one of|either|defaults? to|default:|e\.g\.,?|for example|such as)[\s:*(-]*[`"]([^`"\n]{1,80})[`"]/i
156
+ const NOT_A_VALUE = /^(true|false|null)$/i
157
+
158
+ /** A worked value after "E.g." that is not quoted (`E.g. 2023-12-31 for ...`). */
159
+ const WORKED = /\be\.g\.,?\s+(\d[\w:.+-]{3,30})/i
160
+
161
+ /** Standards a description cites, with the value that follows them. Only what the text itself says. */
162
+ const STANDARDS: [RegExp, (d: string) => string][] = [
163
+ [/ISO 4217|three-letter[^.]{0,60}currency/i, (d) => (/upper ?case/i.test(d) ? 'USD' : 'usd')],
164
+ [/ISO 3166-1|two-letter (?:ISO )?country code/i, () => 'US'],
165
+ [/ISO 639|two-letter language code/i, () => 'en'],
166
+ [/last (?:four|4) (?:digits|characters)/i, () => '4242'],
167
+ [/\bYYYY-MM-DD\b/, () => '2024-05-14'],
168
+ [/^(?:the )?id of\b|^(?:a )?unique identifier\b/i, () => 'a1b2c3d4'],
169
+ [/^(?:the )?url\b/i, () => 'https://example.com/resource'],
170
+ [/^(?:the )?(?:customer's )?email address\b/i, () => 'jane.doe@example.com'],
171
+ [/^(?:the )?(?:customer's )?phone number\b/i, () => '+1-555-0100'],
172
+ [
173
+ /ISO 8601/i,
174
+ (d) => (/\bdate\b/i.test(d) && !/time/i.test(d) ? '2024-05-14' : '2024-05-14T09:30:00Z'),
175
+ ],
176
+ ]
177
+
178
+ /** The value a description gives: one it names after a phrase like "Can be", "e.g." or "Defaults to" (`Defaults to the `id`` names none), or the standard it cites. */
179
+ export function fromDescription(description: unknown): string | undefined {
180
+ if (typeof description !== 'string' || description.length < 8) return undefined
181
+ const m = LIST.exec(description) ?? DIRECT.exec(description)
182
+ const v = (m?.[1] ?? WORKED.exec(description)?.[1])?.trim()
183
+ if (v && !NOT_A_VALUE.test(v)) return v
184
+ for (const [re, value] of STANDARDS) if (re.test(description)) return value(description)
185
+ return undefined
186
+ }
187
+
188
+ // ---------------- patterns ----------------
189
+
190
+ const CLASS_ESCAPES: Record<string, string> = {
191
+ d: '1',
192
+ w: 'a',
193
+ s: ' ',
194
+ D: 'a',
195
+ W: '-',
196
+ S: 'a',
197
+ n: '\n',
198
+ t: '\t',
199
+ b: '',
200
+ B: '',
201
+ }
202
+ let made = new Map<string, string | undefined>()
203
+
204
+ /** A string for a simple regex (literals, classes, groups, `|`, quantifiers); undefined when it cannot be sure. */
205
+ export function fromPattern(pattern: string): string | undefined {
206
+ if (made.has(pattern)) return made.get(pattern)
207
+ let out: string | undefined
208
+ try {
209
+ out = generate(pattern)
210
+ if (!regexOf(pattern)?.test(out)) out = undefined
211
+ } catch {
212
+ out = undefined
213
+ }
214
+ made.set(pattern, out)
215
+ return out
216
+ }
217
+
218
+ function generate(src: string): string {
219
+ let i = 0
220
+ const literal = (c: string | undefined) => (c === undefined ? '' : (CLASS_ESCAPES[c] ?? c))
221
+ const klass = (): string => {
222
+ let first: string | undefined
223
+ if (src[i] === '^') i++
224
+ while (i < src.length && (src[i] !== ']' || first === undefined)) {
225
+ const c = src[i++] as string
226
+ if (first === undefined) first = c === '\\' ? literal(src[i++]) : c
227
+ }
228
+ i++
229
+ return first ?? ''
230
+ }
231
+ const seq = (): string => {
232
+ let out = ''
233
+ while (i < src.length && src[i] !== '|' && src[i] !== ')') {
234
+ const c = src[i++] as string
235
+ if (c === '^' || c === '$') continue
236
+ let atom: string
237
+ if (c === '(') {
238
+ if (src[i] === '?') {
239
+ if (!src.startsWith('?:', i)) throw new Error('unsupported group')
240
+ i += 2
241
+ }
242
+ atom = alt()
243
+ i++
244
+ } else if (c === '[') atom = klass()
245
+ else if (c === '\\') atom = literal(src[i++])
246
+ else if (c === '.') atom = 'a'
247
+ else atom = c
248
+ let times = 1
249
+ const q = src[i]
250
+ if (q === '?' || q === '*') {
251
+ times = 0
252
+ i++
253
+ } else if (q === '+') i++
254
+ else if (q === '{') {
255
+ const m = /^\{(\d+)(?:,\d*)?\}/.exec(src.slice(i))
256
+ if (!m) throw new Error('bad quantifier')
257
+ times = Number(m[1])
258
+ i += m[0].length
259
+ }
260
+ if (src[i] === '?') i++
261
+ if (times > 256) throw new Error('too long')
262
+ out += atom.repeat(times)
263
+ }
264
+ return out
265
+ }
266
+ const alt = (): string => {
267
+ const first = seq()
268
+ while (src[i] === '|') {
269
+ i++
270
+ seq()
271
+ }
272
+ return first
273
+ }
274
+ return alt()
275
+ }
276
+
277
+ // ---------------- the same field elsewhere in the spec ----------------
278
+
279
+ /**
280
+ * The values the document gives a field of this name, most common first. `get` holds what the spec shows for it
281
+ * (an example, a default); `weak` what it only allows or mentions (an enum value, a value a description names).
282
+ */
283
+ export interface ValueIndex {
284
+ get(key: string): unknown[]
285
+ weak(key: string): unknown[]
286
+ }
287
+
288
+ /** Names too generic to carry over from one object to another (a Tag's `name` is not a Pet's). */
289
+ const GENERIC = new Set([
290
+ 'id',
291
+ 'name',
292
+ 'title',
293
+ 'label',
294
+ 'value',
295
+ 'key',
296
+ 'type',
297
+ 'kind',
298
+ 'description',
299
+ 'data',
300
+ 'message',
301
+ 'text',
302
+ 'body',
303
+ 'code',
304
+ 'error',
305
+ 'detail',
306
+ 'summary',
307
+ 'object',
308
+ 'url',
309
+ 'status',
310
+ 'state',
311
+ 'reason',
312
+ 'from',
313
+ 'to',
314
+ 'action',
315
+ 'event',
316
+ 'path',
317
+ 'content',
318
+ 'mode',
319
+ 'level',
320
+ 'role',
321
+ ])
322
+
323
+ /** Descriptions worth reading for a value (most say nothing, so they skip the pattern matching). */
324
+ const MENTIONS = /`|"|ISO|e\.g|digits|YYYY|identifier|\bID of\b/i
325
+
326
+ /** A cap on nodes visited, so one huge inline example cannot dominate. */
327
+ const NODE_BUDGET = 60_000
328
+ const MAX_DEPTH = 8
329
+
330
+ /** Reads every field's example or default, and the leaves of spec examples, once and on first use. */
331
+ export function makeValueIndex(doc: unknown): ValueIndex {
332
+ type Tally = Map<string, Map<string, [unknown, number]>>
333
+ let ready: { strong: Map<string, unknown[]>; weak: Map<string, unknown[]> } | undefined
334
+ const build = () => {
335
+ const strong: Tally = new Map()
336
+ const weak: Tally = new Map()
337
+ let left = NODE_BUDGET
338
+ const add = (into: Tally, key: string, v: unknown) => {
339
+ if (!key || GENERIC.has(key.toLowerCase())) return
340
+ if (typeof v === 'string' ? v === '' || PLACEHOLDER_VALUE.test(v) : typeof v !== 'number')
341
+ return
342
+ const seen = into.get(key) ?? new Map<string, [unknown, number]>()
343
+ const id = JSON.stringify(v)
344
+ const had = seen.get(id)
345
+ if (had) had[1]++
346
+ else seen.set(id, [v, 1])
347
+ into.set(key, seen)
348
+ }
349
+ const known = (s: Schema) =>
350
+ s.const ?? s.example ?? (Array.isArray(s.examples) ? s.examples[0] : undefined) ?? s.default
351
+ /** What the spec only allows or mentions for a field: an enum value, or one its description names. */
352
+ const mentioned = (key: string, s: Schema) => {
353
+ const choice = Array.isArray(s.enum) ? s.enum.find((v: unknown) => v !== '') : undefined
354
+ if (choice !== undefined) add(weak, key, choice)
355
+ else if (typeof s.description === 'string' && MENTIONS.test(s.description))
356
+ add(weak, key, fromDescription(s.description))
357
+ }
358
+ const leaves = (v: unknown, key: string, depth: number) => {
359
+ if (--left < 0 || depth > MAX_DEPTH || v === null) return
360
+ if (Array.isArray(v)) for (const x of v.slice(0, 3)) leaves(x, key, depth + 1)
361
+ else if (typeof v === 'object')
362
+ for (const [k, x] of Object.entries(v)) leaves(x, k, depth + 1)
363
+ else add(strong, key, v)
364
+ }
365
+ const walk = (node: any, depth: number) => {
366
+ if (--left < 0 || depth > 40 || node === null || typeof node !== 'object') return
367
+ if (Array.isArray(node)) {
368
+ for (const x of node) walk(x, depth + 1)
369
+ return
370
+ }
371
+ if (node.properties && typeof node.properties === 'object')
372
+ for (const [k, s] of Object.entries<any>(node.properties))
373
+ if (s && typeof s === 'object') {
374
+ add(strong, k, known(s))
375
+ mentioned(k, s)
376
+ }
377
+ if (typeof node.name === 'string' && node.in && node.schema) {
378
+ add(strong, node.name, node.example ?? known(node.schema))
379
+ mentioned(node.name, node.schema)
380
+ }
381
+ if (node.example !== undefined && typeof node.example === 'object')
382
+ leaves(node.example, '', 0)
383
+ if (node.examples && typeof node.examples === 'object')
384
+ for (const e of Object.values<any>(node.examples).slice(0, 3))
385
+ leaves(e && typeof e === 'object' && 'value' in e ? e.value : e, '', 0)
386
+ for (const v of Object.values(node)) walk(v, depth + 1)
387
+ }
388
+ walk(doc, 0)
389
+ const rank = (t: Tally) => {
390
+ const out = new Map<string, unknown[]>()
391
+ for (const [k, seen] of t)
392
+ out.set(
393
+ k,
394
+ [...seen.values()].sort((a, b) => b[1] - a[1]).map(([v]) => v),
395
+ )
396
+ return out
397
+ }
398
+ return { strong: rank(strong), weak: rank(weak) }
399
+ }
400
+ const read = () => {
401
+ ready ??= build()
402
+ return ready
403
+ }
404
+ return {
405
+ get: (key) => read().strong.get(key) ?? [],
406
+ weak: (key) => read().weak.get(key) ?? [],
407
+ }
408
+ }
package/src/schema.ts CHANGED
@@ -1,4 +1,13 @@
1
1
  // OpenAPI schema -> TypeScript type source, and deterministic example synthesis.
2
+ import {
3
+ clearHintCaches,
4
+ fits,
5
+ fromDescription,
6
+ fromPattern,
7
+ nullable,
8
+ PLACEHOLDER_VALUE,
9
+ type ValueIndex,
10
+ } from './hints'
2
11
 
3
12
  export type Schema = Record<string, any>
4
13
 
@@ -138,8 +147,8 @@ const ERROR_MESSAGES: Record<number, string> = {
138
147
  503: 'Service unavailable',
139
148
  }
140
149
 
141
- /** `status` is the response code being illustrated; -1 means "an error, code unknown" (a `default` response). */
142
- function exampleString(key: string, format?: string, status?: number): string {
150
+ /** A value for a string `format`, when it has one. */
151
+ function byFormat(format?: string): string | undefined {
143
152
  switch (format) {
144
153
  case 'date-time':
145
154
  return '2024-05-14T09:30:00Z'
@@ -165,7 +174,28 @@ function exampleString(key: string, format?: string, status?: number): string {
165
174
  case 'binary':
166
175
  return 'example'
167
176
  }
168
- const k = key.toLowerCase()
177
+ }
178
+
179
+ /** `status` is the response code being illustrated; -1 means "an error, code unknown" (a `default` response). */
180
+ function exampleString(key: string, format?: string, status?: number): string {
181
+ return ruleString(key, format, status) ?? (key ? `Example ${humanize(key)}` : 'Example text')
182
+ }
183
+
184
+ /** What the name and format rules give, or undefined when none of them applies (what is left is the "Example …" text). */
185
+ function ruleString(key: string, format?: string, status?: number): string | undefined {
186
+ const id = `${key}|${format}|${status}`
187
+ const had = named.get(id)
188
+ if (had !== undefined) return had === null ? undefined : had
189
+ const out = chooseString(key, format, status)
190
+ named.set(id, out ?? null)
191
+ return out
192
+ }
193
+ let named = new Map<string, string | null>()
194
+
195
+ function chooseString(key: string, format?: string, status?: number): string | undefined {
196
+ const formatted = byFormat(format)
197
+ if (formatted !== undefined) return formatted
198
+ const k = key.replace(/([a-z\d])([A-Z])/g, '$1_$2').toLowerCase()
169
199
  const isError = status !== undefined && (status === -1 || status >= 400)
170
200
  if (isError) {
171
201
  if (/^(message|detail|reason|error_?description|title)$/.test(k)) {
@@ -180,6 +210,9 @@ function exampleString(key: string, format?: string, status?: number): string {
180
210
  : 'request_failed'
181
211
  if (/^(documentation_?url)$/.test(k)) return 'https://docs.example.com/errors'
182
212
  }
213
+ // a link to an image names its file type (`image_url_png`)
214
+ const image = /^(.*?)_?(?:url|uri|link)_(png|svg|jpe?g|gif|webp)$/.exec(k)
215
+ if (image) return `https://api.example.com/${image[1] || 'image'}.${image[2]}`
183
216
  if (/(^|_)(url|uri|href|link)$/.test(k) || k.endsWith('url')) {
184
217
  const base = k.replace(/_?(html_)?(url|uri|href|link)$/, '').replace(/_/g, '/')
185
218
  return `https://${k.includes('html') ? 'example.com' : 'api.example.com'}${base ? `/${base}` : ''}`
@@ -189,17 +222,19 @@ function exampleString(key: string, format?: string, status?: number): string {
189
222
  [/^(login|username|user_name|handle)$/, 'octocat'],
190
223
  [/^first_?name$|^given_?name$/, 'Jane'],
191
224
  [/^last_?name$|^family_?name|^surname$/, 'Doe'],
192
- [/^(full_?name|display_?name|customer_?name)$/, 'Jane Doe'],
225
+ [/^(full_?name|display_?name|customer_?name)$|(^|_)holder_name$/, 'Jane Doe'],
193
226
  [/^name$/, 'Example name'],
194
227
  [/^title$/, 'Example title'],
195
228
  [/^(description|summary|bio|about)$/, 'A short description of this item.'],
196
- [/^(body|content|text|comment|note|notes)$/, 'Example text content.'],
229
+ [/^(body|content|text|comment|note|notes)$|_text$/, 'Example text content.'],
230
+ [/_html$/, '<p>Example text content.</p>'],
231
+ [/^(homepage|website|site_url)$/, 'https://example.com'],
197
232
  [/^(message|detail|reason)$/, 'The operation completed successfully.'],
198
233
  [/^(error|error_?description)$/, 'Something went wrong.'],
199
234
  [/node_?id$/, 'MDQ6VXNlcjE='],
200
235
  [/(^|_)sha$|commit_?id$|^oid$/, 'a3f5c1e9b7d24c68f0e1a2b3c4d5e6f708192a3b'],
201
236
  [/^ref$/, 'refs/heads/main'],
202
- [/^(branch|default_branch)$/, 'main'],
237
+ [/(^|_)branch$/, 'main'],
203
238
  [/^(color|colour)$/, '0e8a16'],
204
239
  [/^language$/, 'TypeScript'],
205
240
  [/^(phone|phone_?number|mobile)$/, '+1-555-0100'],
@@ -209,26 +244,38 @@ function exampleString(key: string, format?: string, status?: number): string {
209
244
  [/^(postal_?code|zip|zip_?code)$/, '98101'],
210
245
  [/^(line1|address|street|address_?line_?1)$/, '123 Main Street'],
211
246
  [/^line2$/, 'Suite 400'],
212
- [/^currency$/, 'usd'],
213
- [/^(company|organization|org)$/, 'Example Inc.'],
214
- [/^(token|access_?token|api_?key|secret)$/, 'example_token_8f3a2b'],
247
+ [/(^|_)currency$/, 'usd'],
248
+ [/^(company|organization)$/, 'Example Inc.'],
249
+ [/^org$/, 'octo-org'],
250
+ [/(^|_)(token|api_?key|secret)$/, 'example_token_8f3a2b'],
215
251
  [/^(version|api_?version)$/, '1.0.0'],
216
252
  [/^(locale|lang)$/, 'en-US'],
217
253
  [/^(timezone|time_?zone)$/, 'America/Los_Angeles'],
218
254
  [/^(type|kind|category)$/, 'standard'],
219
255
  [/^(status|state)$/, 'active'],
256
+ [/^last4$/, '4242'],
257
+ [/^owner$/, 'octocat'],
258
+ [/^repo$/, 'hello-world'],
259
+ [/(^|_)slug$/, 'my-slug'],
260
+ [/fingerprint$/, 'Xt5EWLLDS7FJjR1c'],
261
+ [/^number$/, '0001'],
262
+ [/^(topic|tag)$/, 'api'],
220
263
  [/^object$/, 'object'],
221
264
  [/(^|_)id$/, 'a1b2c3d4'],
222
265
  [/(^|_)(at|date|time|timestamp)$/, '2024-05-14T09:30:00Z'],
223
266
  ]
224
267
  for (const [re, v] of rules) if (re.test(k)) return v
225
- return k ? `Example ${humanize(key)}` : 'Example text'
226
268
  }
227
269
 
228
270
  function exampleNumber(key: string, integer: boolean, min?: number): number {
229
271
  const k = key.toLowerCase()
230
272
  if (/(^|_)(created|updated|deleted|expires|arrival|paid|canceled)(_at)?$|_at$|timestamp$/.test(k))
231
273
  return 1715679000
274
+ if (integer && /(^|_)(start|end|arrival|due|effective)_date$/.test(k)) return 1715679000
275
+ if (/(^|_)(exp|expiry|expiration)_year$|^year$/.test(k)) return k === 'year' ? 2024 : 2030
276
+ if (/(^|_)(exp|expiry|expiration)_month$/.test(k)) return 12
277
+ if (k === 'month') return 5
278
+ if (k === 'day') return 14
232
279
  if (/^(amount|total|subtotal|price|balance)(_.*)?$/.test(k)) return integer ? 2000 : 19.99
233
280
  if (/(^|_)(count|total_count|number)$/.test(k) || k.endsWith('_count')) return 3
234
281
  if (k === 'quantity' || k === 'qty') return 1
@@ -247,12 +294,142 @@ function exampleBoolean(key: string): boolean {
247
294
  )
248
295
  }
249
296
 
297
+ /** The value a schema's own example, `const` or (3.1) first `examples` entry gives. */
298
+ const exampleOf = (s: Schema | undefined) =>
299
+ s?.const ?? s?.example ?? (Array.isArray(s?.examples) ? s.examples[0] : undefined)
300
+
301
+ /** A string for a field: what the spec says (description, the same field elsewhere), then the name rules, kept inside the schema's limits. */
302
+ function stringValue(schema: Schema, key: string, status?: number, index?: ValueIndex): string {
303
+ const failing = status !== undefined && (status === -1 || status >= 400)
304
+ const ok = (v: unknown): v is string => typeof v === 'string' && fits(schema, v)
305
+ let v: unknown
306
+ if (!failing) {
307
+ let named = described.get(schema)
308
+ if (named === undefined) {
309
+ named = fromDescription(schema.description) ?? ''
310
+ described.set(schema, named)
311
+ }
312
+ v = named || undefined
313
+ if (!ok(v)) v = byFormat(schema.format)
314
+ if (!ok(v) && index) v = index.get(key).find(ok)
315
+ }
316
+ if (!ok(v)) v = ruleString(key, schema.format, status)
317
+ if (!ok(v) && !failing && index) v = index.weak(key).find(ok)
318
+ if (!ok(v)) v = exampleString(key, schema.format, status)
319
+ let out = v as string
320
+ if (!fits(schema, out) && schema.pattern) out = fromPattern(schema.pattern) ?? out
321
+ if (schema.maxLength !== undefined) out = out.slice(0, schema.maxLength)
322
+ if (schema.minLength !== undefined && out.length < schema.minLength)
323
+ out = out.padEnd(schema.minLength, out.slice(-1) || 'a')
324
+ return out
325
+ }
326
+ let described = new WeakMap<object, string>()
327
+
328
+ /** Forgets what one generation run worked out, so a long-lived page or worker does not keep it. */
329
+ export function clearExampleCaches() {
330
+ named = new Map()
331
+ described = new WeakMap()
332
+ inherited = new WeakMap()
333
+ embedded = new WeakMap()
334
+ clearHintCaches()
335
+ }
336
+
337
+ /** A number for a field, inside the schema's bounds and a multiple of `multipleOf`. */
338
+ function numberValue(schema: Schema, key: string, integer: boolean, index?: ValueIndex): number {
339
+ const step = integer ? 1 : 0.01
340
+ let lo = schema.minimum as number | undefined
341
+ if (typeof schema.exclusiveMinimum === 'number') lo = schema.exclusiveMinimum + step
342
+ else if (schema.exclusiveMinimum === true && lo !== undefined) lo += step
343
+ let hi = schema.maximum as number | undefined
344
+ if (typeof schema.exclusiveMaximum === 'number') hi = schema.exclusiveMaximum - step
345
+ else if (schema.exclusiveMaximum === true && hi !== undefined) hi -= step
346
+ const bounded = { type: integer ? 'integer' : 'number', minimum: lo, maximum: hi }
347
+ if (schema.format === 'unix-time') return 1715679000
348
+ const borrowed = index?.get(key).find((v) => fits(bounded, v))
349
+ let n = (borrowed as number | undefined) ?? exampleNumber(key, integer, lo)
350
+ if (lo !== undefined && n < lo) n = lo
351
+ if (hi !== undefined && n > hi) n = hi
352
+ const m = schema.multipleOf
353
+ if (typeof m === 'number' && m > 0) {
354
+ n = Math.ceil(n / m) * m
355
+ if (hi !== undefined && n > hi) n = Math.floor(hi / m) * m
356
+ n = Number(n.toFixed(10))
357
+ }
358
+ return n
359
+ }
360
+
361
+ /** A branch that has no description of its own reads the wrapper's (`anyOf: [{ type: string }, …]` described as "ID of the customer"). */
362
+ let inherited = new WeakMap<object, Schema>()
363
+ function describedAs(pick: Schema, wrapper: Schema): Schema {
364
+ if (pick.$ref || pick.description !== undefined || !wrapper.description) return pick
365
+ const had = inherited.get(pick)
366
+ if (had) return had
367
+ const merged = { ...pick, description: wrapper.description }
368
+ inherited.set(pick, merged)
369
+ return merged
370
+ }
371
+
372
+ /** An object with an `id` and several fields of its own: another resource, not a part of this one. */
373
+ const RESOURCE_FIELDS = 5
374
+
375
+ /**
376
+ * Whether a schema is another resource embedded by value: a resource, a list of them, or a `{ data: [...] }`
377
+ * wrapper around them. An expandable reference (`anyOf: [string, resource]`) is not: the string is what is shown.
378
+ */
379
+ let embedded = new WeakMap<object, boolean>()
380
+ function embeds(
381
+ s: Schema | undefined,
382
+ resolve: (ref: string) => Schema | undefined,
383
+ d = 0,
384
+ ): boolean {
385
+ if (!s || d > 6) return false
386
+ const known = embedded.get(s)
387
+ if (known !== undefined) return known
388
+ let yes = false
389
+ if (s.$ref) yes = embeds(resolve(s.$ref), resolve, d + 1)
390
+ else if (s.allOf) yes = s.allOf.some((b: Schema) => embeds(b, resolve, d + 1))
391
+ else if (s.oneOf || s.anyOf) {
392
+ const branches: Schema[] = s.oneOf ?? s.anyOf
393
+ yes = embeds(branches.find((b) => b.type !== 'null') ?? branches[0], resolve, d + 1)
394
+ } else if (s.type === 'array') yes = embeds(s.items, resolve, d + 1)
395
+ else if (s.properties) {
396
+ const props = s.properties as Record<string, Schema>
397
+ yes =
398
+ ('id' in props && Object.keys(props).length >= RESOURCE_FIELDS) ||
399
+ (!!props.data && embeds(props.data, resolve, d + 1))
400
+ }
401
+ embedded.set(s, yes)
402
+ return yes
403
+ }
404
+
405
+ /** What an error response is made of besides its required fields and spec values. */
406
+ const ERROR_KEYS =
407
+ /^(message|code|type|param|detail|errors?|documentation_url|doc_url|status|title|reason|error_description|request_id)$/i
408
+
409
+ /** The properties of an error object worth showing: required ones, spec values, and the usual error fields. */
410
+ function errorKeys(
411
+ schema: Schema,
412
+ props: Record<string, Schema>,
413
+ resolve: (ref: string) => Schema | undefined,
414
+ ): Set<string> {
415
+ const keep = new Set<string>(schema.required ?? [])
416
+ for (const [k, v] of Object.entries(props)) {
417
+ const s = v.$ref ? resolve(v.$ref) : v
418
+ if (
419
+ ERROR_KEYS.test(k) ||
420
+ exampleOf(s) !== undefined ||
421
+ exampleOf(v) !== undefined ||
422
+ s?.default !== undefined ||
423
+ s?.enum?.length
424
+ )
425
+ keep.add(k)
426
+ }
427
+ return keep
428
+ }
429
+
250
430
  /** Deepest nesting of objects and arrays an example goes into before it settles for an empty one. */
251
431
  const MAX_DEPTH = 6
252
432
 
253
- /** Spec examples that are just the type name (Swagger editor defaults and the like) carry no information. */
254
- const PLACEHOLDER_VALUE = /^(string|str|<string>|\{string\}|string\d+|null)$/i
255
-
256
433
  /**
257
434
  * Replaces placeholder leaves inside a spec-provided example (`"string"`, `"0"`) with a sensible value for the
258
435
  * field's name and format, and leaves everything meaningful alone.
@@ -276,30 +453,6 @@ export function sanitizeExample(
276
453
  return value
277
454
  }
278
455
 
279
- /** Whether `value` has the JSON type the schema declares (schemas with no plain `type` always fit). */
280
- function fits(schema: Schema, value: unknown): boolean {
281
- if (Array.isArray(schema.enum) && value !== null && !schema.enum.includes(value)) return false
282
- const type = Array.isArray(schema.type)
283
- ? schema.type.find((t: string) => t !== 'null')
284
- : schema.type
285
- if (value === null) return true
286
- switch (type) {
287
- case 'string':
288
- return typeof value === 'string'
289
- case 'integer':
290
- case 'number':
291
- return typeof value === 'number'
292
- case 'boolean':
293
- return typeof value === 'boolean'
294
- case 'array':
295
- return Array.isArray(value)
296
- case 'object':
297
- return typeof value === 'object' && !Array.isArray(value)
298
- default:
299
- return true
300
- }
301
- }
302
-
303
456
  /** A hard stop for chains of *required* objects, which is all an example follows past MAX_DEPTH. */
304
457
  const HARD_DEPTH = 14
305
458
 
@@ -317,10 +470,11 @@ export function synthesize(
317
470
  key = '',
318
471
  status?: number,
319
472
  sparse = false,
473
+ index?: ValueIndex,
320
474
  ): unknown {
321
475
  if (!schema) return null
322
476
  const again = (s: Schema | undefined, k = key, d = depth, sn = seen) =>
323
- synthesize(s, resolve, d, sn, k, status, sparse)
477
+ synthesize(s, resolve, d, sn, k, status, sparse, index)
324
478
  if (schema.$ref) {
325
479
  const target = resolve(schema.$ref)
326
480
  // a reference back into itself carries on as "required fields only", which the depth rules then end
@@ -328,10 +482,11 @@ export function synthesize(
328
482
  return again(target, key, depth, [...seen, schema.$ref])
329
483
  }
330
484
  // a spec example that contradicts its own schema (a string field whose example is an array) is ignored
331
- if (schema.example !== undefined && fits(schema, schema.example))
332
- return schema.enum
333
- ? schema.example
334
- : sanitizeExample(schema.example, key, schema.format, status)
485
+ const given = exampleOf(schema)
486
+ if (given !== undefined && fits(schema, given))
487
+ return schema.enum || schema.const !== undefined
488
+ ? given
489
+ : sanitizeExample(given, key, schema.format, status)
335
490
  if (schema.default !== undefined && fits(schema, schema.default)) return schema.default
336
491
  // an enum that lists "" (Stripe's way to clear a field) is not best shown as an empty value
337
492
  if (schema.enum?.length) return schema.enum.find((v: unknown) => v !== '') ?? schema.enum[0]
@@ -343,23 +498,46 @@ export function synthesize(
343
498
  }
344
499
  return merged
345
500
  }
346
- const pick = schema.oneOf?.[0] ?? schema.anyOf?.[0]
347
- if (pick) return again(pick)
501
+ const branches: Schema[] | undefined = schema.oneOf ?? schema.anyOf
502
+ // OpenAPI 3.1 writes an optional field as `anyOf: [{ type: 'null' }, ...]`: the first real branch shows the field
503
+ const pick = branches?.find((b) => b.type !== 'null') ?? branches?.[0]
504
+ 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
517
+ }
348
518
  const type = Array.isArray(schema.type)
349
519
  ? schema.type.find((t: string) => t !== 'null')
350
520
  : schema.type
351
521
  switch (type) {
352
522
  case 'string':
353
- return exampleString(key, schema.format, status)
523
+ return stringValue(schema, key, status, index)
354
524
  case 'integer':
355
- return exampleNumber(key, true, schema.minimum)
525
+ return numberValue(schema, key, true, index)
356
526
  case 'number':
357
- return exampleNumber(key, false, schema.minimum)
527
+ return numberValue(schema, key, false, index)
358
528
  case 'boolean':
359
529
  return exampleBoolean(key)
360
- case 'array':
361
- if (depth >= MAX_DEPTH) return []
362
- return [again(schema.items, singular(key), depth + 1)]
530
+ case 'array': {
531
+ if (depth >= MAX_DEPTH || schema.maxItems === 0) return []
532
+ // as many items as the schema asks for, distinct when it says they must be
533
+ const first = again(schema.items, singular(key), depth + 1)
534
+ const count = Math.min(Math.max(1, schema.minItems ?? 1), 5)
535
+ return Array.from({ length: count }, (_, i) =>
536
+ i && schema.uniqueItems && (typeof first === 'string' || typeof first === 'number')
537
+ ? `${first}${i + 1}`
538
+ : first,
539
+ )
540
+ }
363
541
  default: {
364
542
  if (schema.properties || type === 'object') {
365
543
  const out: Record<string, unknown> = {}
@@ -380,9 +558,25 @@ export function synthesize(
380
558
  'lastName',
381
559
  'username',
382
560
  ].some((p) => p in props)
383
- const keep = sparse ? sparseKeys(schema, props, resolve) : undefined
561
+ // an error is its required fields, spec values and the usual error fields, not every resource it could point to
562
+ const failing = status !== undefined && (status === -1 || status >= 400)
563
+ const keep = sparse
564
+ ? sparseKeys(schema, props, resolve)
565
+ : failing
566
+ ? errorKeys(schema, props, resolve)
567
+ : undefined
384
568
  for (const [k, v] of Object.entries<Schema>(props)) {
385
569
  if (keep && !keep.has(k)) continue
570
+ // a server's own fields are not sent, and a secret it is given is not shown, unless the schema needs them
571
+ const own = v.$ref ? resolve(v.$ref) : v
572
+ const needed = (schema.required as string[] | undefined)?.includes(k)
573
+ if (!needed && (status === undefined ? own?.readOnly : own?.writeOnly)) continue
574
+ if (!needed && own?.deprecated) continue
575
+ // another resource embedded by value is shown as what it is when absent: null where allowed, else left out
576
+ if (!needed && status !== undefined && !failing && embeds(v, resolve)) {
577
+ if (nullable(v, resolve)) out[k] = null
578
+ continue
579
+ }
386
580
  const hint = person && k === 'name' ? 'full_name' : k
387
581
  out[k] = again(v, hint, depth + 1)
388
582
  }
@@ -420,7 +614,8 @@ function sparseKeys(
420
614
  const keep = new Set<string>(schema.required ?? [])
421
615
  for (const [k, v] of Object.entries(props)) {
422
616
  const s = v.$ref ? resolve(v.$ref) : v
423
- const known = s?.example !== undefined || s?.default !== undefined || v.example !== undefined
617
+ const known =
618
+ exampleOf(s) !== undefined || s?.default !== undefined || exampleOf(v) !== undefined
424
619
  if (known || s?.enum?.length) keep.add(k)
425
620
  }
426
621
  // top up with a few plain scalars so the example still shows the shape of the object
@@ -487,7 +682,10 @@ function convertSchema(schema: Schema | undefined, refPrefix: string): Schema {
487
682
  const declared = Array.isArray(schema.type) ? schema.type[0] : schema.type
488
683
  const body: Schema = {
489
684
  ...(declared && declared !== 'string' ? { type: declared } : {}),
490
- enum: schema.nullable ? [...schema.enum, null] : schema.enum,
685
+ enum:
686
+ schema.nullable || (Array.isArray(schema.type) && schema.type.includes('null'))
687
+ ? [...schema.enum.filter((v: unknown) => v !== null), null]
688
+ : schema.enum,
491
689
  }
492
690
  return schema.enum.every((v: unknown) => typeof v === 'string')
493
691
  ? withPlaceholder(body)
@@ -514,6 +712,9 @@ function convertSchema(schema: Schema | undefined, refPrefix: string): Schema {
514
712
  }
515
713
  case 'array':
516
714
  return { ...meta, type: t('array'), items: rec(schema.items ?? {}) }
715
+ case 'null':
716
+ // OpenAPI 3.1 writes an optional field as `anyOf: [{ type: integer }, { type: 'null' }]`: the branch must stay a null
717
+ return { ...meta, type: 'null' }
517
718
  default: {
518
719
  if (!schema.properties && schema.additionalProperties === undefined && type !== 'object')
519
720
  return meta