@specbook/codegen 0.6.1 → 0.8.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 +2 -2
- package/src/build.ts +41 -8
- package/src/hints.ts +408 -0
- package/src/schema.ts +251 -53
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@specbook/codegen",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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.
|
|
43
|
+
"@specbook/core": "0.8.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 =
|
|
364
|
-
|
|
365
|
-
|
|
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 =
|
|
384
|
-
|
|
385
|
-
|
|
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)
|
|
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
|
-
/**
|
|
142
|
-
function
|
|
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
|
-
|
|
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
|
-
[
|
|
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
|
-
[
|
|
213
|
-
[/^(company|organization
|
|
214
|
-
[/^
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
|
347
|
-
|
|
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
|
|
523
|
+
return stringValue(schema, key, status, index)
|
|
354
524
|
case 'integer':
|
|
355
|
-
return
|
|
525
|
+
return numberValue(schema, key, true, index)
|
|
356
526
|
case 'number':
|
|
357
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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)
|