@specbook/codegen 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +6 -2
- package/package.json +54 -3
- package/src/browser.ts +98 -0
- package/src/build.ts +616 -0
- package/src/generate.ts +27 -0
- package/src/index.ts +10 -0
- package/src/js-yaml.d.ts +3 -0
- package/src/schema.ts +573 -0
- package/src/write.ts +50 -0
package/src/schema.ts
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
// OpenAPI schema -> TypeScript type source, and deterministic example synthesis.
|
|
2
|
+
|
|
3
|
+
export type Schema = Record<string, any>
|
|
4
|
+
|
|
5
|
+
const IDENT = /^[A-Za-z_$][\w$]*$/
|
|
6
|
+
|
|
7
|
+
export function safeIdent(name: string): string {
|
|
8
|
+
const cleaned = name.replace(/[^\w$]+(.)?/g, (_, c: string | undefined) =>
|
|
9
|
+
c ? c.toUpperCase() : '',
|
|
10
|
+
)
|
|
11
|
+
const id = /^[0-9]/.test(cleaned) ? `_${cleaned}` : cleaned
|
|
12
|
+
return id || '_'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function pascal(name: string): string {
|
|
16
|
+
const id = safeIdent(name)
|
|
17
|
+
return id.charAt(0).toUpperCase() + id.slice(1)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function propKey(name: string): string {
|
|
21
|
+
return IDENT.test(name) ? name : JSON.stringify(name)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function jsdoc(schema: Schema | undefined, indent = ''): string {
|
|
25
|
+
if (!schema) return ''
|
|
26
|
+
const lines: string[] = []
|
|
27
|
+
if (schema.title && schema.title !== schema.description) lines.push(schema.title)
|
|
28
|
+
if (schema.description) lines.push(...String(schema.description).trim().split('\n'))
|
|
29
|
+
if (schema.format) lines.push(`@format ${schema.format}`)
|
|
30
|
+
if (schema.example !== undefined) lines.push(`@example ${JSON.stringify(schema.example)}`)
|
|
31
|
+
if (schema.default !== undefined) lines.push(`@default ${JSON.stringify(schema.default)}`)
|
|
32
|
+
if (schema.minimum !== undefined) lines.push(`@minimum ${schema.minimum}`)
|
|
33
|
+
if (schema.maximum !== undefined) lines.push(`@maximum ${schema.maximum}`)
|
|
34
|
+
if (schema.deprecated) lines.push('@deprecated')
|
|
35
|
+
if (lines.length === 0) return ''
|
|
36
|
+
const safe = lines.map((l) => l.replace(/\*\//g, '*\\/'))
|
|
37
|
+
if (safe.length === 1) return `${indent}/** ${safe[0]} */\n`
|
|
38
|
+
return `${indent}/**\n${safe.map((l) => `${indent} * ${l}`).join('\n')}\n${indent} */\n`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function refName(ref: string): string {
|
|
42
|
+
return pascal(ref.split('/').pop() as string)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function paren(t: string): string {
|
|
46
|
+
return /[|&]/.test(t) && !/^[([{]/.test(t) ? `(${t})` : t
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function tsType(schema: Schema | undefined, indent = '', p = ''): string {
|
|
50
|
+
if (!schema || typeof schema !== 'object') return 'unknown'
|
|
51
|
+
if (schema.$ref) return `${p}${refName(schema.$ref)}`
|
|
52
|
+
let base: string
|
|
53
|
+
if (schema.enum) {
|
|
54
|
+
base = schema.enum.map((v: unknown) => JSON.stringify(v)).join(' | ') || 'never'
|
|
55
|
+
} else if (schema.allOf) {
|
|
56
|
+
base = schema.allOf.map((s: Schema) => paren(tsType(s, indent, p))).join(' & ')
|
|
57
|
+
} else if (schema.oneOf || schema.anyOf) {
|
|
58
|
+
base = (schema.oneOf ?? schema.anyOf)
|
|
59
|
+
.map((s: Schema) => paren(tsType(s, indent, p)))
|
|
60
|
+
.join(' | ')
|
|
61
|
+
} else {
|
|
62
|
+
const types: string[] = Array.isArray(schema.type) ? schema.type : [schema.type]
|
|
63
|
+
const parts = types.map((t) => single({ ...schema, type: t }, indent, p))
|
|
64
|
+
base = [...new Set(parts)].join(' | ')
|
|
65
|
+
}
|
|
66
|
+
if (schema.nullable && !/\bnull\b/.test(base)) base += ' | null'
|
|
67
|
+
return base
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function single(schema: Schema, indent: string, p: string): string {
|
|
71
|
+
switch (schema.type) {
|
|
72
|
+
case 'string':
|
|
73
|
+
return 'string'
|
|
74
|
+
case 'integer':
|
|
75
|
+
case 'number':
|
|
76
|
+
return 'number'
|
|
77
|
+
case 'boolean':
|
|
78
|
+
return 'boolean'
|
|
79
|
+
case 'null':
|
|
80
|
+
return 'null'
|
|
81
|
+
case 'array':
|
|
82
|
+
return `${paren(tsType(schema.items, indent, p))}[]`
|
|
83
|
+
case 'object':
|
|
84
|
+
case undefined:
|
|
85
|
+
return objectType(schema, indent, p)
|
|
86
|
+
default:
|
|
87
|
+
return 'unknown'
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function objectType(schema: Schema, indent = '', p = ''): string {
|
|
92
|
+
const props = schema.properties as Record<string, Schema> | undefined
|
|
93
|
+
const add = schema.additionalProperties
|
|
94
|
+
if (!props && add === undefined && schema.type === undefined) return 'unknown'
|
|
95
|
+
if (!props && (add === undefined || add === true)) return 'Record<string, unknown>'
|
|
96
|
+
const required = new Set<string>(schema.required ?? [])
|
|
97
|
+
const inner = `${indent} `
|
|
98
|
+
const members: string[] = []
|
|
99
|
+
for (const [k, v] of Object.entries(props ?? {})) {
|
|
100
|
+
members.push(
|
|
101
|
+
`${jsdoc(v, inner)}${inner}${propKey(k)}${required.has(k) ? '' : '?'}: ${tsType(v, inner, p)}`,
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
if (add && add !== true) {
|
|
105
|
+
// With explicit properties, a typed index signature would conflict with them; keep it loose.
|
|
106
|
+
const t = props && Object.keys(props).length > 0 ? 'unknown' : tsType(add, inner, p)
|
|
107
|
+
members.push(`${inner}[key: string]: ${t}`)
|
|
108
|
+
}
|
|
109
|
+
if (members.length === 0) return 'Record<string, never>'
|
|
110
|
+
return `{\n${members.join('\n')}\n${indent}}`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const humanize = (k: string) =>
|
|
114
|
+
k
|
|
115
|
+
.replace(/[_-]+/g, ' ')
|
|
116
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
117
|
+
.trim()
|
|
118
|
+
.toLowerCase()
|
|
119
|
+
|
|
120
|
+
const singular = (k: string) =>
|
|
121
|
+
k.endsWith('ies') ? `${k.slice(0, -3)}y` : k.endsWith('s') ? k.slice(0, -1) : k
|
|
122
|
+
|
|
123
|
+
/** A readable string for a property, chosen by format first, then by what the name suggests. */
|
|
124
|
+
const ERROR_MESSAGES: Record<number, string> = {
|
|
125
|
+
400: 'Bad request',
|
|
126
|
+
401: 'Requires authentication',
|
|
127
|
+
402: 'Payment required',
|
|
128
|
+
403: 'Forbidden',
|
|
129
|
+
404: 'Not found',
|
|
130
|
+
405: 'Method not allowed',
|
|
131
|
+
409: 'Conflict',
|
|
132
|
+
410: 'Gone',
|
|
133
|
+
412: 'Precondition failed',
|
|
134
|
+
422: 'Validation failed',
|
|
135
|
+
429: 'Rate limit exceeded',
|
|
136
|
+
500: 'Internal server error',
|
|
137
|
+
502: 'Bad gateway',
|
|
138
|
+
503: 'Service unavailable',
|
|
139
|
+
}
|
|
140
|
+
|
|
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 {
|
|
143
|
+
switch (format) {
|
|
144
|
+
case 'date-time':
|
|
145
|
+
return '2024-05-14T09:30:00Z'
|
|
146
|
+
case 'date':
|
|
147
|
+
return '2024-05-14'
|
|
148
|
+
case 'time':
|
|
149
|
+
return '09:30:00'
|
|
150
|
+
case 'email':
|
|
151
|
+
return 'jane.doe@example.com'
|
|
152
|
+
case 'uuid':
|
|
153
|
+
return '3f2b1c9e-6c1a-4f55-9a43-1d2e8b7a4c10'
|
|
154
|
+
case 'uri':
|
|
155
|
+
case 'url':
|
|
156
|
+
return 'https://example.com/resource'
|
|
157
|
+
case 'hostname':
|
|
158
|
+
return 'api.example.com'
|
|
159
|
+
case 'ipv4':
|
|
160
|
+
return '192.0.2.10'
|
|
161
|
+
case 'ipv6':
|
|
162
|
+
return '2001:db8::1'
|
|
163
|
+
case 'byte':
|
|
164
|
+
return 'ZXhhbXBsZQ=='
|
|
165
|
+
case 'binary':
|
|
166
|
+
return 'example'
|
|
167
|
+
}
|
|
168
|
+
const k = key.toLowerCase()
|
|
169
|
+
const isError = status !== undefined && (status === -1 || status >= 400)
|
|
170
|
+
if (isError) {
|
|
171
|
+
if (/^(message|detail|reason|error_?description|title)$/.test(k)) {
|
|
172
|
+
return status && status > 0
|
|
173
|
+
? (ERROR_MESSAGES[status] ?? 'Request failed')
|
|
174
|
+
: 'The request could not be completed'
|
|
175
|
+
}
|
|
176
|
+
if (k === 'status') return status && status > 0 ? String(status) : 'error'
|
|
177
|
+
if (/^(error|code)$/.test(k))
|
|
178
|
+
return status && ERROR_MESSAGES[status]
|
|
179
|
+
? ERROR_MESSAGES[status].toLowerCase().replace(/\s+/g, '_')
|
|
180
|
+
: 'request_failed'
|
|
181
|
+
if (/^(documentation_?url)$/.test(k)) return 'https://docs.example.com/errors'
|
|
182
|
+
}
|
|
183
|
+
if (/(^|_)(url|uri|href|link)$/.test(k) || k.endsWith('url')) {
|
|
184
|
+
const base = k.replace(/_?(html_)?(url|uri|href|link)$/, '').replace(/_/g, '/')
|
|
185
|
+
return `https://${k.includes('html') ? 'example.com' : 'api.example.com'}${base ? `/${base}` : ''}`
|
|
186
|
+
}
|
|
187
|
+
const rules: [RegExp, string][] = [
|
|
188
|
+
[/e-?mail/, 'jane.doe@example.com'],
|
|
189
|
+
[/^(login|username|user_name|handle)$/, 'octocat'],
|
|
190
|
+
[/^first_?name$|^given_?name$/, 'Jane'],
|
|
191
|
+
[/^last_?name$|^family_?name|^surname$/, 'Doe'],
|
|
192
|
+
[/^(full_?name|display_?name|customer_?name)$/, 'Jane Doe'],
|
|
193
|
+
[/^name$/, 'Example name'],
|
|
194
|
+
[/^title$/, 'Example title'],
|
|
195
|
+
[/^(description|summary|bio|about)$/, 'A short description of this item.'],
|
|
196
|
+
[/^(body|content|text|comment|note|notes)$/, 'Example text content.'],
|
|
197
|
+
[/^(message|detail|reason)$/, 'The operation completed successfully.'],
|
|
198
|
+
[/^(error|error_?description)$/, 'Something went wrong.'],
|
|
199
|
+
[/node_?id$/, 'MDQ6VXNlcjE='],
|
|
200
|
+
[/(^|_)sha$|commit_?id$|^oid$/, 'a3f5c1e9b7d24c68f0e1a2b3c4d5e6f708192a3b'],
|
|
201
|
+
[/^ref$/, 'refs/heads/main'],
|
|
202
|
+
[/^(branch|default_branch)$/, 'main'],
|
|
203
|
+
[/^(color|colour)$/, '0e8a16'],
|
|
204
|
+
[/^language$/, 'TypeScript'],
|
|
205
|
+
[/^(phone|phone_?number|mobile)$/, '+1-555-0100'],
|
|
206
|
+
[/^(city|town)$/, 'Seattle'],
|
|
207
|
+
[/^(state|province|region)$/, 'WA'],
|
|
208
|
+
[/^(country|country_?code)$/, 'US'],
|
|
209
|
+
[/^(postal_?code|zip|zip_?code)$/, '98101'],
|
|
210
|
+
[/^(line1|address|street|address_?line_?1)$/, '123 Main Street'],
|
|
211
|
+
[/^line2$/, 'Suite 400'],
|
|
212
|
+
[/^currency$/, 'usd'],
|
|
213
|
+
[/^(company|organization|org)$/, 'Example Inc.'],
|
|
214
|
+
[/^(token|access_?token|api_?key|secret)$/, 'example_token_8f3a2b'],
|
|
215
|
+
[/^(version|api_?version)$/, '1.0.0'],
|
|
216
|
+
[/^(locale|lang)$/, 'en-US'],
|
|
217
|
+
[/^(timezone|time_?zone)$/, 'America/Los_Angeles'],
|
|
218
|
+
[/^(type|kind|category)$/, 'standard'],
|
|
219
|
+
[/^(status|state)$/, 'active'],
|
|
220
|
+
[/^object$/, 'object'],
|
|
221
|
+
[/(^|_)id$/, 'a1b2c3d4'],
|
|
222
|
+
[/(^|_)(at|date|time|timestamp)$/, '2024-05-14T09:30:00Z'],
|
|
223
|
+
]
|
|
224
|
+
for (const [re, v] of rules) if (re.test(k)) return v
|
|
225
|
+
return k ? `Example ${humanize(key)}` : 'Example text'
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function exampleNumber(key: string, integer: boolean, min?: number): number {
|
|
229
|
+
const k = key.toLowerCase()
|
|
230
|
+
if (/(^|_)(created|updated|deleted|expires|arrival|paid|canceled)(_at)?$|_at$|timestamp$/.test(k))
|
|
231
|
+
return 1715679000
|
|
232
|
+
if (/^(amount|total|subtotal|price|balance)(_.*)?$/.test(k)) return integer ? 2000 : 19.99
|
|
233
|
+
if (/(^|_)(count|total_count|number)$/.test(k) || k.endsWith('_count')) return 3
|
|
234
|
+
if (k === 'quantity' || k === 'qty') return 1
|
|
235
|
+
if (/^(per_?page|limit|page_?size)$/.test(k)) return 30
|
|
236
|
+
if (/^(page|offset)$/.test(k)) return 1
|
|
237
|
+
if (/(^|_)(size|bytes|length)$/.test(k)) return 1024
|
|
238
|
+
if (/(^|_)(lat|latitude)$/.test(k)) return 47.6062
|
|
239
|
+
if (/(^|_)(lng|lon|longitude)$/.test(k)) return -122.3321
|
|
240
|
+
if (/(^|_)id$/.test(k)) return min ?? 1001
|
|
241
|
+
return min ?? (integer ? 1 : 1.5)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function exampleBoolean(key: string): boolean {
|
|
245
|
+
return !/^(private|draft|archived|disabled|deleted|fork|locked|livemode|prerelease|site_admin|hireable|mergeable|merged|has_.*|is_.*(deleted|private|draft)|.*_(disabled|archived))$/.test(
|
|
246
|
+
key.toLowerCase(),
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const FAKE_BY_FORMAT: Record<string, string> = {
|
|
251
|
+
email: 'internet.email',
|
|
252
|
+
uuid: 'string.uuid',
|
|
253
|
+
uri: 'internet.url',
|
|
254
|
+
url: 'internet.url',
|
|
255
|
+
hostname: 'internet.domainName',
|
|
256
|
+
ipv4: 'internet.ipv4',
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const FAKE_BY_NAME: [RegExp, string][] = [
|
|
260
|
+
[/^(first_?name|given_?name)$/, 'person.firstName'],
|
|
261
|
+
[/^(last_?name|family_?name|surname)$/, 'person.lastName'],
|
|
262
|
+
[/^(full_?name|display_?name)$/, 'person.fullName'],
|
|
263
|
+
[/^name$/, 'word.noun'],
|
|
264
|
+
[/^(user_?name|login|handle)$/, 'internet.username'],
|
|
265
|
+
[/^e?-?mail(_?address)?$/, 'internet.email'],
|
|
266
|
+
[/^(phone|phone_?number|mobile)$/, 'phone.number'],
|
|
267
|
+
[/^(city|town)$/, 'location.city'],
|
|
268
|
+
[/^country$/, 'location.country'],
|
|
269
|
+
[/^(postal_?code|zip|zip_?code)$/, 'location.zipCode'],
|
|
270
|
+
[/^(line1|address|street|address_?line_?1)$/, 'location.streetAddress'],
|
|
271
|
+
[/^(company|organization|org)$/, 'company.name'],
|
|
272
|
+
[/^(title|subject)$/, 'lorem.sentence'],
|
|
273
|
+
[/^(description|summary|bio|about|body|comment|note|notes|message)$/, 'lorem.sentence'],
|
|
274
|
+
[/^(url|website|homepage)$/, 'internet.url'],
|
|
275
|
+
]
|
|
276
|
+
|
|
277
|
+
/** `{{faker.*}}` placeholder for a string leaf, chosen by format first and then by field name. */
|
|
278
|
+
export function fakerFor(key: string, format?: string): string | undefined {
|
|
279
|
+
const path =
|
|
280
|
+
(format && FAKE_BY_FORMAT[format]) ||
|
|
281
|
+
FAKE_BY_NAME.find(([re]) => re.test(key.toLowerCase()))?.[1]
|
|
282
|
+
return path ? `{{faker.${path}}}` : undefined
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Deepest nesting of objects and arrays an example goes into before it settles for an empty one. */
|
|
286
|
+
const MAX_DEPTH = 6
|
|
287
|
+
|
|
288
|
+
/** Spec examples that are just the type name (Swagger editor defaults and the like) carry no information. */
|
|
289
|
+
const PLACEHOLDER_VALUE = /^(string|str|<string>|\{string\}|string\d+|null)$/i
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Replaces placeholder leaves inside a spec-provided example (`"string"`, `"0"`) with a sensible value for the
|
|
293
|
+
* field's name and format, and leaves everything meaningful alone.
|
|
294
|
+
*/
|
|
295
|
+
export function sanitizeExample(
|
|
296
|
+
value: unknown,
|
|
297
|
+
key = '',
|
|
298
|
+
format?: string,
|
|
299
|
+
status?: number,
|
|
300
|
+
): unknown {
|
|
301
|
+
if (typeof value === 'string') {
|
|
302
|
+
const t = value.trim()
|
|
303
|
+
return t === '' || PLACEHOLDER_VALUE.test(t) ? exampleString(key, format, status) : value
|
|
304
|
+
}
|
|
305
|
+
if (Array.isArray(value))
|
|
306
|
+
return value.map((v) => sanitizeExample(v, singular(key), format, status))
|
|
307
|
+
if (value && typeof value === 'object')
|
|
308
|
+
return Object.fromEntries(
|
|
309
|
+
Object.entries(value).map(([k, v]) => [k, sanitizeExample(v, k, undefined, status)]),
|
|
310
|
+
)
|
|
311
|
+
return value
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Whether `value` has the JSON type the schema declares (schemas with no plain `type` always fit). */
|
|
315
|
+
function fits(schema: Schema, value: unknown): boolean {
|
|
316
|
+
if (Array.isArray(schema.enum) && value !== null && !schema.enum.includes(value)) return false
|
|
317
|
+
const type = Array.isArray(schema.type)
|
|
318
|
+
? schema.type.find((t: string) => t !== 'null')
|
|
319
|
+
: schema.type
|
|
320
|
+
if (value === null) return true
|
|
321
|
+
switch (type) {
|
|
322
|
+
case 'string':
|
|
323
|
+
return typeof value === 'string'
|
|
324
|
+
case 'integer':
|
|
325
|
+
case 'number':
|
|
326
|
+
return typeof value === 'number'
|
|
327
|
+
case 'boolean':
|
|
328
|
+
return typeof value === 'boolean'
|
|
329
|
+
case 'array':
|
|
330
|
+
return Array.isArray(value)
|
|
331
|
+
case 'object':
|
|
332
|
+
return typeof value === 'object' && !Array.isArray(value)
|
|
333
|
+
default:
|
|
334
|
+
return true
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** A hard stop for chains of *required* objects, which is all an example follows past MAX_DEPTH. */
|
|
339
|
+
const HARD_DEPTH = 14
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Deterministic, human-readable example: example > default > enum[0] > heuristics by name/format.
|
|
343
|
+
* With `fake`, free-form string leaves become `{{faker.*}}` placeholders so every play is fresh.
|
|
344
|
+
* Always returns something valid for the schema's shape: a cycle or very deep branch ends in `{}` or `[]`,
|
|
345
|
+
* never `null` (which fails validation wherever the schema is not nullable).
|
|
346
|
+
*/
|
|
347
|
+
export function synthesize(
|
|
348
|
+
schema: Schema | undefined,
|
|
349
|
+
resolve: (ref: string) => Schema | undefined,
|
|
350
|
+
depth = 0,
|
|
351
|
+
seen: string[] = [],
|
|
352
|
+
key = '',
|
|
353
|
+
status?: number,
|
|
354
|
+
fake = false,
|
|
355
|
+
sparse = false,
|
|
356
|
+
): unknown {
|
|
357
|
+
if (!schema) return null
|
|
358
|
+
const again = (s: Schema | undefined, k = key, d = depth, sn = seen) =>
|
|
359
|
+
synthesize(s, resolve, d, sn, k, status, fake, sparse)
|
|
360
|
+
if (schema.$ref) {
|
|
361
|
+
const target = resolve(schema.$ref)
|
|
362
|
+
// a reference back into itself carries on as "required fields only", which the depth rules then end
|
|
363
|
+
if (seen.includes(schema.$ref)) return again(target, key, Math.max(depth, MAX_DEPTH), [])
|
|
364
|
+
return again(target, key, depth, [...seen, schema.$ref])
|
|
365
|
+
}
|
|
366
|
+
if (fake && schema.type === 'string' && !schema.enum) {
|
|
367
|
+
const generated = fakerFor(key, schema.format)
|
|
368
|
+
if (generated) return generated
|
|
369
|
+
}
|
|
370
|
+
// a spec example that contradicts its own schema (a string field whose example is an array) is ignored
|
|
371
|
+
if (schema.example !== undefined && fits(schema, schema.example))
|
|
372
|
+
return schema.enum
|
|
373
|
+
? schema.example
|
|
374
|
+
: sanitizeExample(schema.example, key, schema.format, status)
|
|
375
|
+
if (schema.default !== undefined && fits(schema, schema.default)) return schema.default
|
|
376
|
+
// an enum that lists "" (Stripe's way to clear a field) is not best shown as an empty value
|
|
377
|
+
if (schema.enum?.length) return schema.enum.find((v: unknown) => v !== '') ?? schema.enum[0]
|
|
378
|
+
if (schema.allOf) {
|
|
379
|
+
const merged: Record<string, unknown> = {}
|
|
380
|
+
for (const s of schema.allOf as Schema[]) {
|
|
381
|
+
const v = again(s)
|
|
382
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) Object.assign(merged, v)
|
|
383
|
+
}
|
|
384
|
+
return merged
|
|
385
|
+
}
|
|
386
|
+
const pick = schema.oneOf?.[0] ?? schema.anyOf?.[0]
|
|
387
|
+
if (pick) return again(pick)
|
|
388
|
+
const type = Array.isArray(schema.type)
|
|
389
|
+
? schema.type.find((t: string) => t !== 'null')
|
|
390
|
+
: schema.type
|
|
391
|
+
switch (type) {
|
|
392
|
+
case 'string':
|
|
393
|
+
return exampleString(key, schema.format, status)
|
|
394
|
+
case 'integer':
|
|
395
|
+
return exampleNumber(key, true, schema.minimum)
|
|
396
|
+
case 'number':
|
|
397
|
+
return exampleNumber(key, false, schema.minimum)
|
|
398
|
+
case 'boolean':
|
|
399
|
+
return exampleBoolean(key)
|
|
400
|
+
case 'array':
|
|
401
|
+
if (depth >= MAX_DEPTH) return []
|
|
402
|
+
return [again(schema.items, singular(key), depth + 1)]
|
|
403
|
+
default: {
|
|
404
|
+
if (schema.properties || type === 'object') {
|
|
405
|
+
const out: Record<string, unknown> = {}
|
|
406
|
+
const props = schema.properties ?? {}
|
|
407
|
+
if (depth >= MAX_DEPTH) {
|
|
408
|
+
// past the cut-off only what the schema requires is written, so the example stays valid
|
|
409
|
+
if (depth >= HARD_DEPTH) return out
|
|
410
|
+
for (const k of (schema.required ?? []) as string[])
|
|
411
|
+
if (props[k]) out[k] = again(props[k], k, depth + 1)
|
|
412
|
+
return out
|
|
413
|
+
}
|
|
414
|
+
// a `name` next to an email or first_name is a person's name, not a thing's
|
|
415
|
+
const person = [
|
|
416
|
+
'email',
|
|
417
|
+
'first_name',
|
|
418
|
+
'firstName',
|
|
419
|
+
'last_name',
|
|
420
|
+
'lastName',
|
|
421
|
+
'username',
|
|
422
|
+
].some((p) => p in props)
|
|
423
|
+
const keep = sparse ? sparseKeys(schema, props, resolve) : undefined
|
|
424
|
+
for (const [k, v] of Object.entries<Schema>(props)) {
|
|
425
|
+
if (keep && !keep.has(k)) continue
|
|
426
|
+
const hint = person && k === 'name' ? 'full_name' : k
|
|
427
|
+
out[k] = again(v, hint, depth + 1)
|
|
428
|
+
}
|
|
429
|
+
return out
|
|
430
|
+
}
|
|
431
|
+
return null
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Properties worth showing first when nothing is required; common identifying fields lead. */
|
|
437
|
+
const COMMON = [
|
|
438
|
+
'name',
|
|
439
|
+
'email',
|
|
440
|
+
'description',
|
|
441
|
+
'title',
|
|
442
|
+
'type',
|
|
443
|
+
'amount',
|
|
444
|
+
'currency',
|
|
445
|
+
'customer',
|
|
446
|
+
'status',
|
|
447
|
+
]
|
|
448
|
+
const FALLBACK = 4
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Which properties a sparse example keeps: required ones, ones the spec gives an example, default or
|
|
452
|
+
* closed set of values for, and, when that leaves nothing, a few plain scalar fields so the example still shows the shape.
|
|
453
|
+
* Everything else stays available through the editor's autocomplete.
|
|
454
|
+
*/
|
|
455
|
+
function sparseKeys(
|
|
456
|
+
schema: Schema,
|
|
457
|
+
props: Record<string, Schema>,
|
|
458
|
+
resolve: (ref: string) => Schema | undefined,
|
|
459
|
+
): Set<string> {
|
|
460
|
+
const keep = new Set<string>(schema.required ?? [])
|
|
461
|
+
for (const [k, v] of Object.entries(props)) {
|
|
462
|
+
const s = v.$ref ? resolve(v.$ref) : v
|
|
463
|
+
const known = s?.example !== undefined || s?.default !== undefined || v.example !== undefined
|
|
464
|
+
if (known || s?.enum?.length) keep.add(k)
|
|
465
|
+
}
|
|
466
|
+
// top up with a few plain scalars so the example still shows the shape of the object
|
|
467
|
+
const names = Object.keys(props)
|
|
468
|
+
const scalar = (k: string) => {
|
|
469
|
+
const v = props[k] as Schema
|
|
470
|
+
const s = (v.$ref ? resolve(v.$ref) : v) ?? {}
|
|
471
|
+
return !s.properties && s.type !== 'object' && s.type !== 'array'
|
|
472
|
+
}
|
|
473
|
+
const ranked = [
|
|
474
|
+
...COMMON.filter((k) => names.includes(k)),
|
|
475
|
+
...names.filter((k) => !COMMON.includes(k)),
|
|
476
|
+
].filter(scalar)
|
|
477
|
+
for (const k of ranked) {
|
|
478
|
+
if (keep.size >= FALLBACK) break
|
|
479
|
+
keep.add(k)
|
|
480
|
+
}
|
|
481
|
+
return keep
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ---------------- JSON Schema (for editor hover docs, completion and validation) ----------------
|
|
485
|
+
|
|
486
|
+
const PLACEHOLDER = { type: 'string', pattern: '^\\s*\\{\\{[^}]+\\}\\}\\s*$' }
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* OpenAPI schema -> JSON Schema for the request editor. Every non-string leaf also accepts a
|
|
490
|
+
* `"{{variable}}"` placeholder string, since requests are templates. Descriptions stay on the
|
|
491
|
+
* outer node so hover docs keep working.
|
|
492
|
+
*/
|
|
493
|
+
export function toJsonSchema(schema: Schema | undefined, refPrefix: string): Schema {
|
|
494
|
+
const out = convertSchema(schema, refPrefix)
|
|
495
|
+
// `nullable` next to a $ref / allOf / oneOf / anyOf (OpenAPI 3.0) has no place of its own in JSON Schema
|
|
496
|
+
if (
|
|
497
|
+
schema &&
|
|
498
|
+
typeof schema === 'object' &&
|
|
499
|
+
schema.nullable === true &&
|
|
500
|
+
(schema.$ref || schema.allOf || schema.oneOf || schema.anyOf)
|
|
501
|
+
)
|
|
502
|
+
return { anyOf: [out, { type: 'null' }] }
|
|
503
|
+
return out
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function convertSchema(schema: Schema | undefined, refPrefix: string): Schema {
|
|
507
|
+
if (!schema || typeof schema !== 'object') return {}
|
|
508
|
+
if (schema.$ref) {
|
|
509
|
+
const out: Schema = { $ref: `${refPrefix}${refName(schema.$ref)}` }
|
|
510
|
+
if (schema.description) out.description = schema.description
|
|
511
|
+
return out
|
|
512
|
+
}
|
|
513
|
+
const meta: Schema = {}
|
|
514
|
+
if (schema.title) meta.title = schema.title
|
|
515
|
+
if (schema.description) meta.description = String(schema.description)
|
|
516
|
+
if (schema.deprecated) meta.deprecated = true
|
|
517
|
+
if (schema.default !== undefined) meta.default = schema.default
|
|
518
|
+
if (schema.example !== undefined) meta.examples = [schema.example]
|
|
519
|
+
|
|
520
|
+
const rec = (s: Schema) => toJsonSchema(s, refPrefix)
|
|
521
|
+
const withPlaceholder = (body: Schema): Schema => ({ ...meta, anyOf: [body, PLACEHOLDER] })
|
|
522
|
+
|
|
523
|
+
if (schema.allOf) return { ...meta, allOf: schema.allOf.map(rec) }
|
|
524
|
+
if (schema.oneOf) return { ...meta, oneOf: schema.oneOf.map(rec) }
|
|
525
|
+
if (schema.anyOf) return { ...meta, anyOf: schema.anyOf.map(rec) }
|
|
526
|
+
if (schema.enum) {
|
|
527
|
+
const declared = Array.isArray(schema.type) ? schema.type[0] : schema.type
|
|
528
|
+
const body: Schema = {
|
|
529
|
+
...(declared && declared !== 'string' ? { type: declared } : {}),
|
|
530
|
+
enum: schema.nullable ? [...schema.enum, null] : schema.enum,
|
|
531
|
+
}
|
|
532
|
+
return schema.enum.every((v: unknown) => typeof v === 'string')
|
|
533
|
+
? withPlaceholder(body)
|
|
534
|
+
: { ...meta, ...body }
|
|
535
|
+
}
|
|
536
|
+
const type = Array.isArray(schema.type) ? schema.type[0] : schema.type
|
|
537
|
+
const nullable = schema.nullable || (Array.isArray(schema.type) && schema.type.includes('null'))
|
|
538
|
+
const t = (name: string) => (nullable ? [name, 'null'] : name)
|
|
539
|
+
switch (type) {
|
|
540
|
+
case 'string':
|
|
541
|
+
return {
|
|
542
|
+
...meta,
|
|
543
|
+
type: t('string'),
|
|
544
|
+
...(schema.format ? { format: schema.format } : {}),
|
|
545
|
+
...(schema.maxLength !== undefined ? { maxLength: schema.maxLength } : {}),
|
|
546
|
+
}
|
|
547
|
+
case 'integer':
|
|
548
|
+
case 'number':
|
|
549
|
+
case 'boolean': {
|
|
550
|
+
const body: Schema = { type: t(type) }
|
|
551
|
+
if (schema.minimum !== undefined) body.minimum = schema.minimum
|
|
552
|
+
if (schema.maximum !== undefined) body.maximum = schema.maximum
|
|
553
|
+
return withPlaceholder(body)
|
|
554
|
+
}
|
|
555
|
+
case 'array':
|
|
556
|
+
return { ...meta, type: t('array'), items: rec(schema.items ?? {}) }
|
|
557
|
+
default: {
|
|
558
|
+
if (!schema.properties && schema.additionalProperties === undefined && type !== 'object')
|
|
559
|
+
return meta
|
|
560
|
+
const props = Object.fromEntries(
|
|
561
|
+
Object.entries<Schema>(schema.properties ?? {}).map(([k, v]) => [k, rec(v)]),
|
|
562
|
+
)
|
|
563
|
+
const out: Schema = { ...meta, type: t('object'), properties: props }
|
|
564
|
+
if (schema.required?.length) out.required = schema.required
|
|
565
|
+
if (schema.additionalProperties && schema.additionalProperties !== true) {
|
|
566
|
+
out.additionalProperties = rec(schema.additionalProperties)
|
|
567
|
+
} else if (schema.additionalProperties === true) {
|
|
568
|
+
out.additionalProperties = true
|
|
569
|
+
}
|
|
570
|
+
return out
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
package/src/write.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { dirname, join, relative, sep } from 'node:path'
|
|
3
|
+
import { type GenerateOptions, generate } from './generate'
|
|
4
|
+
|
|
5
|
+
async function filesUnder(dir: string): Promise<string[]> {
|
|
6
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
7
|
+
const nested = await Promise.all(
|
|
8
|
+
entries.map((e) => (e.isDirectory() ? filesUnder(join(dir, e.name)) : [join(dir, e.name)])),
|
|
9
|
+
)
|
|
10
|
+
return nested.flat()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let counter = 0
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Writes `content` to `full` so a reader never sees half a file: into a sibling temp file, then renamed
|
|
17
|
+
* over the target (atomic on one file system). A file that already holds this content is left alone,
|
|
18
|
+
* which keeps its timestamp and spares a watching dev server a reload for nothing.
|
|
19
|
+
*/
|
|
20
|
+
async function writeAtomically(full: string, content: string): Promise<void> {
|
|
21
|
+
const current = await readFile(full, 'utf8').catch(() => undefined)
|
|
22
|
+
if (current === content) return
|
|
23
|
+
const tmp = `${full}.${process.pid}.${counter++}.tmp`
|
|
24
|
+
try {
|
|
25
|
+
await writeFile(tmp, content)
|
|
26
|
+
await rename(tmp, full)
|
|
27
|
+
} catch (e) {
|
|
28
|
+
await rm(tmp, { force: true })
|
|
29
|
+
throw e
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function generateToDisk(opts: GenerateOptions & { outDir: string }) {
|
|
34
|
+
const out = await generate(opts)
|
|
35
|
+
const root = join(opts.outDir, opts.service)
|
|
36
|
+
const keep = new Set(out.files.map((f) => f.path))
|
|
37
|
+
for (const f of out.files) {
|
|
38
|
+
const full = join(root, f.path)
|
|
39
|
+
await mkdir(dirname(full), { recursive: true })
|
|
40
|
+
await writeAtomically(full, f.content)
|
|
41
|
+
}
|
|
42
|
+
// drop files an earlier generation left behind (renamed or removed operations and examples),
|
|
43
|
+
// after the new ones are in place so nothing a running loader needs is ever missing
|
|
44
|
+
for (const file of await filesUnder(root)) {
|
|
45
|
+
// another generation's file in flight is not ours to remove
|
|
46
|
+
if (file.endsWith('.tmp')) continue
|
|
47
|
+
if (!keep.has(relative(root, file).split(sep).join('/'))) await rm(file, { force: true })
|
|
48
|
+
}
|
|
49
|
+
return { root, ...out }
|
|
50
|
+
}
|