@specbook/codegen 0.0.0-stage → 0.2.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/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
+ export 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
+ }