@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/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(/ /g, ' ')
|
|
32
|
+
.replace(/</g, '<')
|
|
33
|
+
.replace(/>/g, '>')
|
|
34
|
+
.replace(/"/g, '"')
|
|
35
|
+
.replace(/'/g, "'")
|
|
36
|
+
.replace(/&/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
|
+
}
|
package/src/generate.ts
ADDED
|
@@ -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'
|
package/src/js-yaml.d.ts
ADDED