@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/src/build.ts ADDED
@@ -0,0 +1,616 @@
1
+ import {
2
+ jsdoc,
3
+ objectType,
4
+ pascal,
5
+ type Schema,
6
+ safeIdent,
7
+ sanitizeExample,
8
+ synthesize,
9
+ toJsonSchema,
10
+ tsType,
11
+ } from './schema'
12
+
13
+ export interface GenerateOptions {
14
+ /** Path/URL to the spec, or an already-parsed object. */
15
+ spec: string | object
16
+ service: string
17
+ /** Import specifier for the runtime package. */
18
+ corePackage?: string
19
+ }
20
+
21
+ /**
22
+ * The one-line label an operation is listed under: its summary, else the first line of its description.
23
+ * Real specs leave `summary` out or set it to "" (Stripe does, for 16 of its 600 operations), and put
24
+ * HTML in the description (`<p>List apple pay domains.</p>`), so both are cleaned; with nothing left there
25
+ * is no label and the viewer shows the path.
26
+ */
27
+ export function titleOf(op: { summary?: string; description?: string }): string | undefined {
28
+ const plain = (s: string | undefined) =>
29
+ (s ?? '')
30
+ .replace(/<[^>]*>/g, ' ')
31
+ .replace(/&nbsp;/g, ' ')
32
+ .replace(/&lt;/g, '<')
33
+ .replace(/&gt;/g, '>')
34
+ .replace(/&quot;/g, '"')
35
+ .replace(/&#39;/g, "'")
36
+ .replace(/&amp;/g, '&')
37
+ const line = (s: string | undefined) =>
38
+ plain(s)
39
+ .split(/\r?\n/)
40
+ .map((l) => l.replace(/\s+/g, ' ').trim())
41
+ .find(Boolean)
42
+ const text = line(op.summary) ?? line(op.description)
43
+ if (!text) return undefined
44
+ if (text.length <= 120) return text
45
+ const cut = text.slice(0, 120)
46
+ return `${cut.slice(0, Math.max(cut.lastIndexOf(' '), 60))}…`
47
+ }
48
+
49
+ export interface GeneratedFile {
50
+ path: string
51
+ content: string
52
+ }
53
+
54
+ export interface ManifestExample {
55
+ name: string
56
+ title: string
57
+ source: 'spec' | 'generated'
58
+ }
59
+
60
+ interface ExampleDoc {
61
+ name: string
62
+ title: string
63
+ summary?: string
64
+ source: 'spec' | 'generated'
65
+ path?: Record<string, unknown>
66
+ query?: Record<string, unknown>
67
+ headers?: Record<string, unknown>
68
+ body?: unknown
69
+ }
70
+
71
+ export interface ManifestOperation {
72
+ id: string
73
+ method: string
74
+ path: string
75
+ summary?: string
76
+ description?: string
77
+ tags: string[]
78
+ statuses: number[]
79
+ examples: ManifestExample[]
80
+ /** the spec requires credentials for this operation */
81
+ secured?: boolean
82
+ file: string
83
+ }
84
+
85
+ /** `info.x-logo` (a Redoc convention): only absolute http(s) or data-image URLs are trusted. */
86
+ export function logoOf(
87
+ info: any,
88
+ ): { url: string; altText?: string; backgroundColor?: string } | undefined {
89
+ const l = info?.['x-logo']
90
+ if (!l || typeof l.url !== 'string' || !/^(https?:\/\/|data:image\/)/i.test(l.url))
91
+ return undefined
92
+ return {
93
+ url: l.url,
94
+ ...(typeof l.altText === 'string' ? { altText: l.altText } : {}),
95
+ ...(typeof l.backgroundColor === 'string' ? { backgroundColor: l.backgroundColor } : {}),
96
+ }
97
+ }
98
+
99
+ /** Statuses a spec's `default` response stands in for (typed, mockable and listed in the manifest). */
100
+ export const DEFAULT_ERROR_STATUSES = [400, 401, 402, 403, 404, 409, 422, 429, 500]
101
+
102
+ const METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch'] as const
103
+ const BODY_ENCODINGS: [string, string][] = [
104
+ ['application/json', 'json'],
105
+ ['application/x-www-form-urlencoded', 'form'],
106
+ ['multipart/form-data', 'multipart'],
107
+ ['application/graphql', 'graphql'],
108
+ ['text/plain', 'text'],
109
+ ]
110
+ const BINARY_MIME = /^(application\/(octet-stream|pdf|zip|gzip)|(image|audio|video)\/)/
111
+
112
+ function findJsonLike(content: Record<string, any> | undefined) {
113
+ if (!content) return undefined
114
+ for (const [mime, enc] of BODY_ENCODINGS) {
115
+ if (content[mime])
116
+ return {
117
+ schema: content[mime].schema as Schema | undefined,
118
+ encoding: enc,
119
+ media: content[mime],
120
+ }
121
+ }
122
+ const json = Object.keys(content).find((m) => /json/.test(m))
123
+ if (json)
124
+ return {
125
+ schema: content[json].schema as Schema | undefined,
126
+ encoding: 'json',
127
+ media: content[json],
128
+ }
129
+ const binary = Object.keys(content).find((m) => BINARY_MIME.test(m))
130
+ if (binary)
131
+ return {
132
+ schema: content[binary].schema as Schema | undefined,
133
+ encoding: 'binary',
134
+ media: content[binary],
135
+ mediaType: binary,
136
+ }
137
+ return undefined
138
+ }
139
+
140
+ /** The spec's per-field `encoding` (style/explode) as an array style, for form bodies. */
141
+ function formArrayStyles(media: any): Record<string, string> {
142
+ const out: Record<string, string> = {}
143
+ for (const [field, e] of Object.entries<any>(media?.encoding ?? {})) {
144
+ // deepObject is the bracket notation the default already writes (Stripe's `expand[]`)
145
+ if (e?.style === 'deepObject') continue
146
+ if (e?.style === 'spaceDelimited') out[field] = 'space'
147
+ else if (e?.style === 'pipeDelimited') out[field] = 'pipe'
148
+ else if (e?.explode === false) out[field] = 'comma'
149
+ else if (e?.explode === true || e?.style === 'form') out[field] = 'repeat'
150
+ }
151
+ return out
152
+ }
153
+
154
+ const SAMPLE_FILE = 'sample.txt'
155
+ const SAMPLE_TEXT = 'specbook sample file\n'
156
+
157
+ const slug = (s: string) =>
158
+ s
159
+ .toLowerCase()
160
+ .replace(/[^a-z0-9]+/g, '-')
161
+ .replace(/^-+|-+$/g, '') || 'example'
162
+
163
+ const isBinaryString = (s: Schema | undefined) =>
164
+ s?.type === 'string' &&
165
+ (s.format === 'binary' || s.contentEncoding === 'binary' || s.contentMediaType)
166
+
167
+ /** Dotted body paths of upload fields (`format: binary`), looking through refs and arrays. */
168
+ function fileFields(
169
+ schema: Schema | undefined,
170
+ resolve: (ref: string) => Schema | undefined,
171
+ prefix = '',
172
+ depth = 0,
173
+ ): string[] {
174
+ if (!schema || depth > 4) return []
175
+ const s = schema.$ref ? resolve(schema.$ref) : schema
176
+ if (!s) return []
177
+ if (isBinaryString(s)) return prefix ? [prefix] : []
178
+ if (s.type === 'array') return fileFields(s.items, resolve, prefix, depth + 1)
179
+ return Object.entries<Schema>(s.properties ?? {}).flatMap(([k, v]) =>
180
+ fileFields(v, resolve, prefix ? `${prefix}.${k}` : k, depth + 1),
181
+ )
182
+ }
183
+
184
+ function setPaths(value: unknown, paths: string[], to: string): unknown {
185
+ const walk = (node: unknown, keys: string[]): unknown => {
186
+ if (Array.isArray(node)) return node.map((n) => walk(n, keys))
187
+ if (keys.length === 0) return to
188
+ const [head, ...rest] = keys as [string, ...string[]]
189
+ if (node !== null && typeof node === 'object' && head in node)
190
+ return { ...node, [head]: walk((node as Record<string, unknown>)[head], rest) }
191
+ return node
192
+ }
193
+ return paths.reduce((acc, p) => walk(acc, p.split('.')), value)
194
+ }
195
+
196
+ function opId(method: string, path: string, given?: string): string {
197
+ if (given) return pascal(given)
198
+ const segs = path
199
+ .split('/')
200
+ .filter(Boolean)
201
+ .map((s) => (s.startsWith('{') ? `By${pascal(s.slice(1, -1))}` : pascal(s)))
202
+ return pascal(method) + segs.join('')
203
+ }
204
+
205
+ export interface GenerateFlags {
206
+ /**
207
+ * Produce only what a running UI reads (schemas, examples, docs, manifest, operation metadata) and skip
208
+ * the TypeScript sources: `types.ts`, `operations/*.ts`, `tags/*.ts` and `index.ts`. The data files are
209
+ * byte-identical to a full run; only the work and the files that are source code are left out.
210
+ */
211
+ dataOnly?: boolean
212
+ }
213
+
214
+ /** What a runtime needs to describe an operation without its generated module. */
215
+ export interface OperationMeta {
216
+ service: string
217
+ id: string
218
+ method: string
219
+ path: string
220
+ encoding?: string
221
+ mediaType?: string
222
+ files?: string[]
223
+ formArrays?: Record<string, string>
224
+ security?: boolean
225
+ }
226
+
227
+ /** The generator proper, from an OpenAPI 3 document that is already parsed and bundled. */
228
+ export function generateFromDoc(
229
+ doc: any,
230
+ opts: Omit<GenerateOptions, 'spec'>,
231
+ flags: GenerateFlags = {},
232
+ ): {
233
+ files: GeneratedFile[]
234
+ operations: ManifestOperation[]
235
+ metas: Record<string, OperationMeta>
236
+ } {
237
+ const core = opts.corePackage ?? '@specbook/core'
238
+ const ts = !flags.dataOnly
239
+ const metas: Record<string, OperationMeta> = {}
240
+ const schemas: Record<string, Schema> = doc.components?.schemas ?? {}
241
+ const hasSchemes = Object.keys(doc.components?.securitySchemes ?? {}).length > 0
242
+ const resolve = (ref: string): Schema | undefined => {
243
+ const m = /^#\/components\/schemas\/(.+)$/.exec(ref)
244
+ return m ? schemas[m[1] as string] : undefined
245
+ }
246
+ const resolveAny = <T>(obj: any): T => {
247
+ if (obj?.$ref) {
248
+ const parts = String(obj.$ref).replace(/^#\//, '').split('/')
249
+ return parts.reduce((acc: any, p: string) => acc?.[p], doc) as T
250
+ }
251
+ return obj
252
+ }
253
+ const firstExample = (examples: Record<string, any> | undefined) =>
254
+ resolveAny<any>(Object.values(examples ?? {})[0])?.value
255
+
256
+ const files: GeneratedFile[] = []
257
+ const operations: ManifestOperation[] = []
258
+
259
+ const typeBlocks: string[] = []
260
+ for (const [name, schema] of ts ? Object.entries(schemas) : []) {
261
+ const id = pascal(name)
262
+ const isObj =
263
+ !schema.$ref &&
264
+ !schema.enum &&
265
+ !schema.allOf &&
266
+ !schema.oneOf &&
267
+ !schema.anyOf &&
268
+ (schema.type === 'object' || (!schema.type && schema.properties)) &&
269
+ schema.properties
270
+ const body = isObj ? objectType(schema) : undefined
271
+ typeBlocks.push(
272
+ body?.startsWith('{')
273
+ ? `${jsdoc(schema)}export interface ${id} ${body}`
274
+ : `${jsdoc(schema)}export type ${id} = ${body ?? tsType(schema)}`,
275
+ )
276
+ }
277
+ if (ts)
278
+ files.push({
279
+ path: 'types.ts',
280
+ content: `// Generated by specbook codegen from the "${opts.service}" spec. Do not edit.\n\n${
281
+ typeBlocks.join('\n\n') || 'export {}'
282
+ }\n`,
283
+ })
284
+
285
+ // ---- JSON Schemas for the request editor (shared definitions + one small file per operation)
286
+ const defsId = `specbook://${opts.service}/defs`
287
+ files.push({
288
+ path: 'schemas/definitions.schema.json',
289
+ content: `${JSON.stringify({
290
+ $id: defsId,
291
+ $defs: Object.fromEntries(
292
+ Object.entries(schemas).map(([name, sch]) => [pascal(name), toJsonSchema(sch, '#/$defs/')]),
293
+ ),
294
+ })}\n`,
295
+ })
296
+
297
+ // ---- operations
298
+ const usedIds = new Set<string>()
299
+ const byTag = new Map<string, string[]>()
300
+ for (const [path, item] of Object.entries<any>(doc.paths ?? {})) {
301
+ for (const method of METHODS) {
302
+ const op = item[method]
303
+ if (!op) continue
304
+ let id = opId(method, path, op.operationId)
305
+ while (usedIds.has(id)) id += '_'
306
+ usedIds.add(id)
307
+
308
+ const params = [...(item.parameters ?? []), ...(op.parameters ?? [])].map((p: any) =>
309
+ resolveAny<any>(p),
310
+ )
311
+ const group = (where: string) =>
312
+ params
313
+ .filter((p: any) => p.in === where)
314
+ .map((p: any) => ({
315
+ name: p.name as string,
316
+ required: where === 'path' ? true : Boolean(p.required),
317
+ example: (p.example ?? firstExample(p.examples)) as unknown,
318
+ schema: {
319
+ ...(p.schema ?? {}),
320
+ description: p.schema?.description ?? p.description,
321
+ } as Schema,
322
+ }))
323
+ const paramType = (where: string) => {
324
+ if (!ts) return undefined
325
+ const g = group(where)
326
+ if (!g.length) return undefined
327
+ const schema: Schema = {
328
+ type: 'object',
329
+ properties: Object.fromEntries(g.map((p) => [p.name, p.schema])),
330
+ required: g.filter((p) => p.required).map((p) => p.name),
331
+ }
332
+ return objectType(schema, ' ', 'T.')
333
+ }
334
+ const pathT = paramType('path')
335
+ const queryT = paramType('query')
336
+ const headerT = paramType('header')
337
+ const paramsT = [
338
+ pathT && `path: ${pathT}`,
339
+ queryT && `query: ${queryT}`,
340
+ headerT && `headers: ${headerT}`,
341
+ ]
342
+ .filter(Boolean)
343
+ .join('; ')
344
+
345
+ const reqBody = resolveAny<any>(op.requestBody)
346
+ const body = findJsonLike(reqBody?.content)
347
+ const bodyT = ts && body?.schema ? tsType(body.schema, ' ', 'T.') : 'never'
348
+
349
+ const responseTypes: string[] = []
350
+ const statuses: number[] = []
351
+ const exampleFiles: GeneratedFile[] = []
352
+ let defaultType: string | undefined
353
+ let hasDefault = false
354
+ let defaultSchema: Schema | undefined
355
+ const responseSchemas: Record<string, Schema> = {}
356
+ for (const [code, rawRes] of Object.entries<any>(op.responses ?? {})) {
357
+ if (code === 'default') {
358
+ const found = findJsonLike(resolveAny<any>(rawRes)?.content)
359
+ if (found?.schema) {
360
+ hasDefault = true
361
+ if (ts) defaultType = tsType(found.schema, ' ', 'T.')
362
+ defaultSchema = toJsonSchema(found.schema, `${defsId}#/$defs/`)
363
+ const example = found.media?.example
364
+ ? sanitizeExample(found.media.example, '', undefined, -1)
365
+ : synthesize(found.schema, resolve, 0, [], '', -1)
366
+ exampleFiles.push({
367
+ path: `examples/${id}/default.response.json`,
368
+ content: `${JSON.stringify(example, null, 2)}\n`,
369
+ })
370
+ }
371
+ continue
372
+ }
373
+ if (!/^\d{3}$/.test(code)) continue
374
+ const res = resolveAny<any>(rawRes)
375
+ const found = findJsonLike(res?.content)
376
+ statuses.push(Number(code))
377
+ if (found?.schema) responseSchemas[code] = toJsonSchema(found.schema, `${defsId}#/$defs/`)
378
+ if (ts)
379
+ responseTypes.push(
380
+ `${code}: ${found?.schema ? tsType(found.schema, ' ', 'T.') : 'unknown'}`,
381
+ )
382
+ if (found?.schema) {
383
+ const example = found.media?.example
384
+ ? sanitizeExample(found.media.example, '', undefined, Number(code))
385
+ : synthesize(found.schema, resolve, 0, [], '', Number(code))
386
+ exampleFiles.push({
387
+ path: `examples/${id}/${code}.response.json`,
388
+ content: `${JSON.stringify(example, null, 2)}\n`,
389
+ })
390
+ }
391
+ }
392
+ if (hasDefault) {
393
+ for (const st of DEFAULT_ERROR_STATUSES) {
394
+ if (statuses.includes(st)) continue
395
+ statuses.push(st)
396
+ if (defaultSchema) responseSchemas[String(st)] = defaultSchema
397
+ if (defaultType) responseTypes.push(`${st}: ${defaultType}`)
398
+ }
399
+ }
400
+ // ---- request examples: spec-defined first, then generated default and dynamic
401
+ const fileFieldPaths = body?.encoding === 'multipart' ? fileFields(body.schema, resolve) : []
402
+ const wholeBodyFile = body?.encoding === 'binary'
403
+ const sample = `./${SAMPLE_FILE}`
404
+ const paramsFor = (fake: boolean) => {
405
+ const pick = (where: string) => {
406
+ const out: Record<string, unknown> = {}
407
+ for (const p of group(where)) {
408
+ if (p.example !== undefined) out[p.name] = p.example
409
+ else if (p.required)
410
+ out[p.name] = synthesize(p.schema, resolve, 0, [], p.name, undefined, fake)
411
+ }
412
+ return Object.keys(out).length ? out : undefined
413
+ }
414
+ return { path: pick('path'), query: pick('query'), headers: pick('header') }
415
+ }
416
+ const bodyFor = (value: unknown) =>
417
+ wholeBodyFile
418
+ ? sample
419
+ : fileFieldPaths.length
420
+ ? setPaths(value, fileFieldPaths, sample)
421
+ : value
422
+ const docs: ExampleDoc[] = []
423
+ const take = (name: string) => {
424
+ let n = slug(name)
425
+ while (docs.some((d) => d.name === n)) n += '-2'
426
+ return n
427
+ }
428
+ for (const [key, raw] of Object.entries<any>(body?.media?.examples ?? {})) {
429
+ const ex = resolveAny<any>(raw)
430
+ if (ex?.value === undefined) continue
431
+ docs.push({
432
+ name: take(key),
433
+ title: ex.summary ?? key,
434
+ summary: ex.description,
435
+ source: 'spec',
436
+ ...paramsFor(false),
437
+ body: bodyFor(sanitizeExample(ex.value)),
438
+ })
439
+ }
440
+ if (body?.media?.example !== undefined)
441
+ docs.push({
442
+ name: take('example'),
443
+ title: 'Example',
444
+ source: 'spec',
445
+ ...paramsFor(false),
446
+ body: bodyFor(sanitizeExample(body.media.example)),
447
+ })
448
+ const plain = {
449
+ ...paramsFor(false),
450
+ body: body?.schema
451
+ ? bodyFor(synthesize(body.schema, resolve, 0, [], '', undefined, false, true))
452
+ : undefined,
453
+ }
454
+ const dynamic = {
455
+ ...paramsFor(true),
456
+ body: body?.schema
457
+ ? bodyFor(synthesize(body.schema, resolve, 0, [], '', undefined, true, true))
458
+ : undefined,
459
+ }
460
+ docs.push({ name: take('default'), title: 'Default', source: 'generated', ...plain })
461
+ if (JSON.stringify(dynamic) !== JSON.stringify(plain))
462
+ docs.push({
463
+ name: take('dynamic'),
464
+ title: 'Dynamic',
465
+ summary: 'Generated values, fresh on every play.',
466
+ source: 'generated',
467
+ ...dynamic,
468
+ })
469
+ for (const d of docs)
470
+ exampleFiles.push({
471
+ path: `examples/${id}/${d.name}.example.json`,
472
+ content: `${JSON.stringify(d, null, 2)}\n`,
473
+ })
474
+ if (wholeBodyFile || fileFieldPaths.length)
475
+ exampleFiles.push({ path: `examples/${id}/${SAMPLE_FILE}`, content: SAMPLE_TEXT })
476
+ files.push(...exampleFiles)
477
+
478
+ // request editor schema: { path, query, headers, data }
479
+ const refPrefix = `${defsId}#/$defs/`
480
+ const section = (where: string) => {
481
+ const g = group(where)
482
+ if (!g.length) return undefined
483
+ return {
484
+ type: 'object',
485
+ properties: Object.fromEntries(g.map((p) => [p.name, toJsonSchema(p.schema, refPrefix)])),
486
+ required: g.filter((p) => p.required).map((p) => p.name),
487
+ }
488
+ }
489
+ const requestSchema: Schema = {
490
+ $id: `specbook://${opts.service}/${id}.request`,
491
+ type: 'object',
492
+ description: [op.summary, op.description].filter(Boolean).join('\n\n') || undefined,
493
+ properties: {
494
+ ...(section('path') ? { path: section('path') } : {}),
495
+ ...(section('query') ? { query: section('query') } : {}),
496
+ ...(section('header') ? { headers: section('header') } : {}),
497
+ ...(body?.schema ? { data: toJsonSchema(body.schema, refPrefix) } : {}),
498
+ },
499
+ additionalProperties: false,
500
+ }
501
+ files.push({
502
+ path: `schemas/${id}.responses.schema.json`,
503
+ content: `${JSON.stringify(responseSchemas)}\n`,
504
+ })
505
+ files.push({
506
+ path: `schemas/${id}.request.schema.json`,
507
+ content: `${JSON.stringify(requestSchema)}\n`,
508
+ })
509
+
510
+ const effective = op.security ?? doc.security
511
+ const secured = hasSchemes && Array.isArray(effective) && effective.length > 0
512
+ const securityLine = !hasSchemes
513
+ ? ''
514
+ : `, security: ${Array.isArray(effective) && effective.length > 0 ? 'true' : 'false'}`
515
+ // a binary body's declared type (`application/octet-stream`) is what the server expects as Content-Type
516
+ const mediaType =
517
+ body &&
518
+ 'mediaType' in body &&
519
+ typeof body.mediaType === 'string' &&
520
+ !body.mediaType.includes('*')
521
+ ? body.mediaType
522
+ : undefined
523
+ const encodingLine =
524
+ (body ? `, encoding: '${body.encoding}'` : '') +
525
+ (mediaType ? `, mediaType: ${JSON.stringify(mediaType)}` : '')
526
+ const arrayStyles = body?.encoding === 'form' ? formArrayStyles(body.media) : {}
527
+ const arraysLine = Object.keys(arrayStyles).length
528
+ ? `, formArrays: ${JSON.stringify(arrayStyles)}`
529
+ : ''
530
+ const filesLine = fileFieldPaths.length ? `, files: ${JSON.stringify(fileFieldPaths)}` : ''
531
+ const summary = titleOf(op)
532
+
533
+ metas[id] = {
534
+ service: opts.service,
535
+ id,
536
+ method: method.toUpperCase(),
537
+ path,
538
+ ...(body ? { encoding: body.encoding } : {}),
539
+ ...(mediaType ? { mediaType } : {}),
540
+ ...(fileFieldPaths.length ? { files: fileFieldPaths } : {}),
541
+ ...(Object.keys(arrayStyles).length ? { formArrays: arrayStyles } : {}),
542
+ ...(hasSchemes ? { security: Array.isArray(effective) && effective.length > 0 } : {}),
543
+ }
544
+ if (ts)
545
+ files.push({
546
+ path: `operations/${id}.ts`,
547
+ content: `import { defineOperation } from '${core}'
548
+ import type * as T from '../types'
549
+
550
+ ${jsdoc({ description: [...new Set([op.summary, op.description].filter(Boolean))].join('\n\n'), deprecated: op.deprecated })}export const ${id} = defineOperation<{
551
+ params: { ${paramsT} }
552
+ body: ${bodyT}
553
+ responses: { ${responseTypes.join('; ')} }
554
+ }>({ service: ${JSON.stringify(opts.service)}, id: ${JSON.stringify(id)}, method: '${method.toUpperCase()}', path: ${JSON.stringify(path)}${encodingLine}${filesLine}${arraysLine}${securityLine} })
555
+ `,
556
+ })
557
+ const tags: string[] = op.tags?.length ? op.tags : ['default']
558
+ for (const t of tags) byTag.set(t, [...(byTag.get(t) ?? []), id])
559
+ // The long text stays out of the registry; the docs page loads it for the operation it shows.
560
+ files.push({
561
+ path: `docs/${id}.json`,
562
+ content: `${JSON.stringify({
563
+ ...(op.description && op.description !== summary ? { description: op.description } : {}),
564
+ examples: docs.map((d) => ({ name: d.name, title: d.title, source: d.source })),
565
+ })}\n`,
566
+ })
567
+ operations.push({
568
+ id,
569
+ method: method.toUpperCase(),
570
+ path,
571
+ summary,
572
+ ...(op.description && op.description !== summary ? { description: op.description } : {}),
573
+ tags,
574
+ statuses,
575
+ examples: docs.map((d) => ({ name: d.name, title: d.title, source: d.source })),
576
+ ...(secured ? { secured: true } : {}),
577
+ file: `operations/${id}.ts`,
578
+ })
579
+ }
580
+ }
581
+
582
+ // ---- index + per-tag barrels + manifest
583
+ const exportsFor = (ids: string[]) =>
584
+ ids
585
+ .sort()
586
+ .map((i) => `export { ${i} } from './operations/${i}'`)
587
+ .join('\n')
588
+ if (ts)
589
+ files.push({
590
+ path: 'index.ts',
591
+ content: `${exportsFor([...usedIds])}\nexport type * from './types'\n`,
592
+ })
593
+ for (const [tag, ids] of ts ? byTag : []) {
594
+ files.push({
595
+ path: `tags/${safeIdent(tag)}.ts`,
596
+ content: `${exportsFor(ids).replace(/'\.\/operations/g, "'../operations")}\n`,
597
+ })
598
+ }
599
+ files.push({
600
+ path: 'manifest.json',
601
+ content: `${JSON.stringify(
602
+ {
603
+ service: opts.service,
604
+ title: doc.info?.title,
605
+ version: doc.info?.version,
606
+ ...(logoOf(doc.info) ? { logo: logoOf(doc.info) } : {}),
607
+ servers: (doc.servers ?? []).map((s: any) => s.url),
608
+ tags: [...byTag.keys()],
609
+ operations,
610
+ },
611
+ null,
612
+ 2,
613
+ )}\n`,
614
+ })
615
+ return { files, operations, metas }
616
+ }
@@ -0,0 +1,27 @@
1
+ import SwaggerParser from '@apidevtools/swagger-parser'
2
+ import swagger2openapi from 'swagger2openapi'
3
+ import {
4
+ type GeneratedFile,
5
+ type GenerateOptions,
6
+ generateFromDoc,
7
+ type ManifestOperation,
8
+ } from './build'
9
+
10
+ export * from './build'
11
+
12
+ async function loadOpenApi3(spec: string | object): Promise<any> {
13
+ const bundled: any = await SwaggerParser.bundle(spec as any)
14
+ if (bundled.swagger?.startsWith('2')) {
15
+ const res = await swagger2openapi.convertObj(bundled, { patch: true, warnOnly: true } as any)
16
+ return res.openapi
17
+ }
18
+ return bundled
19
+ }
20
+
21
+ export async function generate(opts: GenerateOptions): Promise<{
22
+ files: GeneratedFile[]
23
+ operations: ManifestOperation[]
24
+ }> {
25
+ const { files, operations } = generateFromDoc(await loadOpenApi3(opts.spec), opts)
26
+ return { files, operations }
27
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export type {
2
+ GeneratedFile,
3
+ GenerateFlags,
4
+ GenerateOptions,
5
+ ManifestOperation,
6
+ OperationMeta,
7
+ } from './build'
8
+ export { generate, generateFromDoc } from './generate'
9
+ export { synthesize, tsType } from './schema'
10
+ export { generateToDisk } from './write'
@@ -0,0 +1,3 @@
1
+ declare module 'js-yaml' {
2
+ export function load(text: string): unknown
3
+ }