galbe 0.3.0 → 0.5.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.
@@ -0,0 +1,479 @@
1
+ import { semver } from 'bun'
2
+ import { transformSync } from '@swc/core'
3
+ import { resolve, relative, dirname } from 'path'
4
+ import { load as ymlLoad } from 'js-yaml'
5
+ import { OpenAPIV3 } from 'openapi-types'
6
+
7
+ type SchemaEntry = {
8
+ key: string
9
+ prefix: string
10
+ schema: string
11
+ dependsOn: Set<string>
12
+ usedBy: Set<string>
13
+ }
14
+ type EndpointEntry = {
15
+ version?: string
16
+ visibility?: 'public' | 'private'
17
+ scope?: string
18
+ method?: 'get' | 'put' | 'patch' | 'post' | 'delete' | 'options' | 'head'
19
+ path?: string
20
+ schema?: { imports: Record<string, string>; name: string; def: string }
21
+ endpoint?: { meta?: string; def?: string }
22
+ }
23
+
24
+ // Util
25
+ const unref = (code: string, cb: (m: string) => string) => code.replaceAll(/%ref:([^%]*)%/g, (_, m) => cb(m))
26
+ const refToPath = (ref: string, basePath?: string) => {
27
+ let result: string | null = null
28
+ let match = ref.match(/^#\/(paths|components)\/(.*)$/)
29
+ if (!match) return null
30
+ let [_, type, path] = match
31
+ if (type === 'components') {
32
+ let [t] = path.split('/')
33
+ if (!t) return null
34
+ if (t === 'schemas') result = `schemas/commons.schema`
35
+ if (t === 'requestBodies') result = `schemas/requests.schema`
36
+ if (t === 'responses') result = `schemas/responses.schema`
37
+ } else if (type === 'paths') {
38
+ result = `schemas/${path}`
39
+ }
40
+ if (!result) return null
41
+ if (basePath) {
42
+ let relPath = relative(basePath, result)
43
+ return relPath.includes('/') ? relPath : `./${relPath}` //resolve(basePath, relPath)
44
+ } else return result
45
+ }
46
+ const orderDeps = (deps: Record<string, SchemaEntry>) => {
47
+ let stack = Object.keys(deps)
48
+ let l = new Set<string>()
49
+ const ascend = (d: { dependsOn: Set<string> }) => {
50
+ for (let p of d.dependsOn.keys()) {
51
+ if (p in deps) {
52
+ ascend(deps[p])
53
+ if (p in deps && !l.has(p)) {
54
+ l.add(p)
55
+ stack.splice(stack.indexOf(p), 1)
56
+ }
57
+ }
58
+ }
59
+ }
60
+ while (stack.length) {
61
+ let k = stack.pop()
62
+ if (!k) continue
63
+ let d = deps[k]
64
+ ascend(d)
65
+ if (!l.has(k)) {
66
+ l.add(k)
67
+ }
68
+ }
69
+ return Object.fromEntries([...l].map(k => [k, deps[k]]))
70
+ }
71
+ const writeCodeFile = async (path: string, content: string, target: 'js' | 'ts') => {
72
+ if (target === 'js') {
73
+ content = transformSync(content, {
74
+ jsc: {
75
+ parser: {
76
+ syntax: 'typescript'
77
+ },
78
+ preserveAllComments: true,
79
+ target: 'esnext'
80
+ }
81
+ }).code
82
+ }
83
+ await Bun.write(`${path}.${target}`, content)
84
+ }
85
+
86
+ const parseOapiSchema = (
87
+ os?: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject,
88
+ details: { id?: string; title?: string; description?: string } = {},
89
+ extra?: { media?: string }
90
+ ): string => {
91
+ if (!os) return `$T.any()`
92
+ //@ts-ignore
93
+ if (os?.$ref) return `%ref:${os.$ref}%`
94
+ os = os as OpenAPIV3.SchemaObject
95
+ let options: typeof details & {
96
+ min?: number
97
+ max?: number
98
+ exclusiveMin?: number
99
+ exclusiveMax?: number
100
+ minLength?: number
101
+ maxLength?: number
102
+ pattern?: string
103
+ minItems?: number
104
+ maxItems?: number
105
+ unique?: boolean
106
+ } = {
107
+ ...details,
108
+ title: os.title,
109
+ description: os.description
110
+ }
111
+ let hasOptions = Object.values(options).some(v => !!v)
112
+ let optArg = hasOptions ? `, ${JSON.stringify(options)}` : ''
113
+ let anyOf = os.oneOf || os.anyOf || os.allOf
114
+ if (anyOf?.length) {
115
+ return `$T.union([${anyOf.map(s => parseOapiSchema(s as OpenAPIV3.SchemaObject)).join(',')}], ${JSON.stringify(
116
+ options
117
+ )})`
118
+ }
119
+ if (os.type === 'boolean') return `$T.boolean(${hasOptions ? JSON.stringify(options) : ''})`
120
+ if (os.type === 'number') {
121
+ let { max, min, exclusiveMax, exclusiveMin } = {
122
+ max: os.maximum !== undefined && !os.exclusiveMaximum ? os.maximum : undefined,
123
+ min: os.minimum !== undefined && !os.exclusiveMinimum ? os.minimum : undefined,
124
+ exclusiveMax: os.maximum !== undefined && os.exclusiveMaximum ? os.maximum : undefined,
125
+ exclusiveMin: os.minimum !== undefined && os.exclusiveMinimum ? os.minimum : undefined
126
+ }
127
+ options = { ...options, min, max, exclusiveMax, exclusiveMin }
128
+ hasOptions = Object.values(options).some(v => !!v)
129
+ return `$T.number(${hasOptions ? JSON.stringify(options) : ''})`
130
+ }
131
+ if (os.type === 'integer') {
132
+ let max = os.maximum !== undefined && !os.exclusiveMaximum ? os.maximum : undefined
133
+ let min = os.minimum !== undefined && !os.exclusiveMinimum ? os.minimum : undefined
134
+ let exclusiveMax = os.maximum !== undefined && os.exclusiveMaximum ? os.maximum : undefined
135
+ let exclusiveMin = os.minimum !== undefined && os.exclusiveMinimum ? os.minimum : undefined
136
+ options = { ...options, min, max, exclusiveMax, exclusiveMin }
137
+ hasOptions = Object.values(options).some(v => !!v)
138
+ return `$T.integer(${hasOptions ? JSON.stringify(options) : ''})`
139
+ }
140
+ if (os.type === 'string') {
141
+ if (os.format === 'binary') return `$T.byteArray(${hasOptions ? JSON.stringify(options) : ''})`
142
+ let minLength = os.minLength
143
+ let maxLength = os.maxLength
144
+ let pattern = os.pattern
145
+ options = { ...options, minLength, maxLength, pattern }
146
+ hasOptions = Object.values(options).some(v => !!v)
147
+ return `$T.string(${hasOptions ? JSON.stringify(options) : ''})`
148
+ }
149
+ if (os.type === 'array') {
150
+ let minItems = os.minItems
151
+ let maxItems = os.maxItems
152
+ let unique = os.uniqueItems
153
+ options = { ...options, minItems, maxItems, unique }
154
+ return `$T.array(${parseOapiSchema(os?.items)}${optArg})`
155
+ }
156
+ if (os.type === 'object') {
157
+ if (extra?.media === 'multipart/form-data') {
158
+ return `$T.multipartForm({${Object.entries(os?.properties || {})
159
+ .map(([k, v]) => `${k}:${parseOapiSchema(v)}`)
160
+ .join(',')}}${optArg})`
161
+ }
162
+ if (extra?.media === 'application/x-www-form-urlencoded') {
163
+ return `$T.urlForm({${Object.entries(os?.properties || {})
164
+ .map(([k, v]) => `${k}:${parseOapiSchema(v)}`)
165
+ .join(',')}}${optArg})`
166
+ }
167
+ return `$T.object({${Object.entries(os?.properties || {})
168
+ .map(([k, v]) => `${k}:${parseOapiSchema(v)}`)
169
+ .join(',')}}${optArg})`
170
+ }
171
+ throw new Error(`Unknown schema type ${os.type}`)
172
+ }
173
+
174
+ const buildSchemaIndex = (def: OpenAPIV3.Document) => {
175
+ let index: Record<string, SchemaEntry> = {}
176
+
177
+ const initSchema = (k: string, s: any, kind: 'schemas' | 'requestBodies' | 'responses') => {
178
+ let schema = ''
179
+ let dependsOn = new Set<string>()
180
+ if (kind === 'schemas') schema = parseOapiSchema(s, { id: k })
181
+ else if (kind === 'requestBodies' || kind === 'responses') {
182
+ let schemas = [
183
+ ...new Set(
184
+ Object.entries((s as OpenAPIV3.RequestBodyObject).content || {}).map(([media, v]) =>
185
+ parseOapiSchema(v.schema, { id: k }, { media })
186
+ )
187
+ )
188
+ ]
189
+ schema = schemas.length <= 0 ? '' : schemas.length === 1 ? schemas[0] : `$T.union([${schemas.join(',')}])`
190
+ }
191
+ schema = unref(schema, m => {
192
+ let l = m.split('/')
193
+ dependsOn.add(m)
194
+ return l[l.length - 1]
195
+ })
196
+ index[`#/components/${kind}/${k}`] = {
197
+ key: k,
198
+ prefix: '',
199
+ schema,
200
+ dependsOn,
201
+ usedBy: new Set()
202
+ }
203
+ }
204
+ for (let [k, v] of Object.entries(def.components?.schemas || {})) initSchema(k, v, 'schemas')
205
+ for (let [k, v] of Object.entries(def.components?.requestBodies || {})) initSchema(k, v, 'requestBodies')
206
+ for (let [k, v] of Object.entries(def.components?.responses || {})) initSchema(k, v, 'responses')
207
+
208
+ Object.entries(index).forEach(([k, v]) => {
209
+ for (let d of v.dependsOn) index[d].usedBy.add(k)
210
+ })
211
+
212
+ return index
213
+ }
214
+
215
+ const parseEndpointDef = (method: string, path: string, def?: OpenAPIV3.OperationObject) => {
216
+ if (!def) return {}
217
+ let imports = {}
218
+ let p = path.replaceAll(/\{([^\}]*)\}/g, ':$1')
219
+ let description = def.summary || def.description
220
+ let pathName = path.replaceAll(/\/\{[^\}]*\}/g, 'X').replaceAll(/[^$\w\d-_]([$\w\d-_])/g, (_, $1) => $1.toUpperCase())
221
+ let schemaName = `${method}${pathName}`.replace(/^\w/, c => c.toUpperCase())
222
+
223
+ let meta = '/**\n'
224
+ if (description) meta += ` * ${description.replace('\n', '\n * ')}\n`
225
+ if (def.operationId) meta += ` * @operationId ${def.operationId}\n`
226
+ if (def.externalDocs) meta += ` * @externalDocs ${def.externalDocs}\n`
227
+ if (def.tags) meta += ` * @tags ${def.tags.join(' ')}\n`
228
+ if (def.deprecated) meta == ' * @deprecated\n'
229
+ meta += ' */'
230
+ let endpoint = `${method}("${p}", ${schemaName}, ctx => {\n throw new Error("Not implemented")\n})`
231
+
232
+ let sp = { path: {}, query: {}, header: {}, body: {}, formData: {} } // TODO handle body and formData cases
233
+ for (let _p of def?.parameters || []) {
234
+ // @ts-ignore: TODO handle refs cases
235
+ if (_p.$ref) continue
236
+ let p = _p as OpenAPIV3.ParameterObject
237
+ let o = (s: string) => (p.in !== 'path' && !p.required ? `$T.optional(${s})` : s)
238
+ sp[p.in][p.name] = o(parseOapiSchema(p.schema))
239
+ }
240
+
241
+ let [schemaParams, schemaQuery, schemaHeaders] = [
242
+ { g: 'params', o: 'path' },
243
+ { g: 'query', o: 'query' },
244
+ { g: 'headers', o: 'header' }
245
+ ].map(({ g, o }) =>
246
+ Object.keys(sp[o]).length
247
+ ? ` ${g}: {${Object.entries(sp[o])
248
+ .map(([k, v]) => `${k}:${v}`)
249
+ .join(',')}}`
250
+ : ''
251
+ )
252
+
253
+ let body = ''
254
+ let _rb = def?.requestBody as OpenAPIV3.ReferenceObject
255
+ if (_rb?.$ref) {
256
+ body = unref(` body: %ref:${_rb.$ref}%`, m => {
257
+ let l = m.split('/')
258
+ imports[l[l.length - 1]] = m
259
+ return l[l.length - 1]
260
+ })
261
+ } else {
262
+ let rb = def?.requestBody as OpenAPIV3.RequestBodyObject
263
+ let o = (s: string) => (!rb.required ? `$T.optional(${s})` : s)
264
+ let bs = [
265
+ ...new Set(
266
+ Object.entries(rb?.content || {}).map(([media, v]) =>
267
+ unref(parseOapiSchema(v.schema, undefined, { media }), m => {
268
+ let l = m.split('/')
269
+ imports[l[l.length - 1]] = m
270
+ return l[l.length - 1]
271
+ })
272
+ )
273
+ )
274
+ ]
275
+ body = bs.length === 1 ? ` body: ${o(bs[0])}` : bs.length > 1 ? ` body: ${o(`$T.union(${bs.join(',')})`)}` : ''
276
+ }
277
+
278
+ let resp = ''
279
+ let r = def?.responses
280
+ let rs = Object.fromEntries(
281
+ Object.entries(r || {}).map(([status, sv]) => {
282
+ let entries: string[] = []
283
+ for (let [_type, tv] of Object.entries((sv as OpenAPIV3.ResponseObject)?.content || {})) {
284
+ entries.push(
285
+ unref(parseOapiSchema(tv.schema), m => {
286
+ let l = m.split('/')
287
+ imports[l[l.length - 1]] = m
288
+ return l[l.length - 1]
289
+ })
290
+ )
291
+ }
292
+ return [status, [...new Set(entries)]]
293
+ })
294
+ )
295
+
296
+ if (Object.keys(rs).length) {
297
+ resp = ` response: {${Object.entries(rs)
298
+ .filter(([_, v]) => v.length)
299
+ .map(([s, v]) => `${s}: ${v.length === 1 ? v[0] : v.length > 1 ? `$T.union(${v.join(',')})` : ''}`)
300
+ .join(',')}}`
301
+ } else resp = ''
302
+
303
+ let schema = [schemaHeaders, schemaParams, schemaQuery, body, resp].filter(s => s)
304
+
305
+ return {
306
+ schema: {
307
+ name: schemaName,
308
+ imports,
309
+ def: schema.length ? `{\n${schema.join(',\n')}\n}` : ''
310
+ },
311
+ endpoint: {
312
+ meta,
313
+ def: endpoint
314
+ }
315
+ }
316
+ }
317
+
318
+ const parseEndpoints = (def: OpenAPIV3.Document) => {
319
+ let endpoints: Record<string, EndpointEntry> = {}
320
+ for (let [fullPath, pathVal] of Object.entries(def.paths || {})) {
321
+ if (!pathVal) continue
322
+ let match = fullPath.match(/^\/?(v\d+[^\/]*\/)?(?:\/?(public|private))?\/?([^\/]+)\/?(.*)$/)
323
+ if (!match) continue
324
+ let [_, version, visibility, scope, path] = [...match]
325
+ path = `/${path}`
326
+ let methods = ['get', 'put', 'patch', 'post', 'delete', 'options', 'head'] as const
327
+ for (let m of methods) {
328
+ let endpointDef = pathVal?.[m]
329
+ if (!endpointDef) continue
330
+ let ref = `#/paths${version ? `/${version}` : ''}${visibility ? `/${visibility}` : ''}${
331
+ scope ? `/${scope}` : ''
332
+ }/${m}${path}`
333
+ let { schema, endpoint } = parseEndpointDef(m, fullPath, endpointDef)
334
+ endpoints[ref] = {
335
+ version,
336
+ visibility: visibility as 'public' | 'private',
337
+ method: m,
338
+ scope,
339
+ path,
340
+ schema,
341
+ endpoint
342
+ }
343
+ }
344
+ }
345
+ return endpoints
346
+ }
347
+
348
+ const writeFiles = async (
349
+ path: string,
350
+ endpoints: Record<string, EndpointEntry>,
351
+ schemaIndex: Record<string, SchemaEntry>,
352
+ target: 'js' | 'ts'
353
+ ) => {
354
+ const typeMap = {
355
+ schemas: 'commons',
356
+ requestBodies: 'requests',
357
+ responses: 'responses'
358
+ }
359
+ const parseSchemasToFile = (
360
+ schemas: Record<string, SchemaEntry>,
361
+ type: 'schemas' | 'requestBodies' | 'responses'
362
+ ) => {
363
+ if (Object.keys(schemas).length === 0) return ''
364
+ let imports: Record<string, string[]> = {}
365
+ let decl: string[] = []
366
+ Object.entries(schemas).forEach(([k, s]) => {
367
+ if (s.key === s.schema && s.dependsOn.size === 1) {
368
+ let depMatch = [...s.dependsOn][0].match(/^#\/components\/([^\/]+)\/([^\/]+)/)
369
+ if (!depMatch) return
370
+ let [_, depOrig, depName] = [...depMatch]
371
+ decl.push(`export { ${depName} } from './${typeMap[depOrig]}.schema'\n`)
372
+ return
373
+ }
374
+ for (let dep of [k, ...s.dependsOn]) {
375
+ let depMatch = dep.match(/^#\/components\/([^\/]+)\/([^\/]+)/)
376
+ if (!depMatch) continue
377
+ let [_, depOrig, depName] = [...depMatch]
378
+ if (depOrig !== type) {
379
+ if (!(depOrig in imports)) imports[depOrig] = []
380
+ imports[depOrig].push(depName)
381
+ }
382
+ }
383
+ decl.push(`export const ${s.key} = ${s.schema}\nexport type T${s.key} = Static<typeof ${s.key}>\n`)
384
+ })
385
+ if (decl.length === 0) return ''
386
+ return `import type { Static } from 'galbe/schema'\nimport { $T } from 'galbe'\n${Object.entries(imports)
387
+ .map(([k, v]) => `import { ${v.join(', ')} } from './${typeMap[k]}.schema'\n`)
388
+ .join('\n')}\n${decl.join('\n')}\n`
389
+ }
390
+
391
+ const sMaps = [
392
+ { g: 'commons', o: 'schemas' },
393
+ { g: 'requests', o: 'requestBodies' },
394
+ { g: 'responses', o: 'responses' }
395
+ ] as const
396
+ for (let { g, o } of sMaps) {
397
+ let s = parseSchemasToFile(
398
+ orderDeps(
399
+ Object.fromEntries(
400
+ Object.entries(schemaIndex).filter(([k, _]) => {
401
+ return k.match(new RegExp(`^#/components/${o}/`))
402
+ })
403
+ )
404
+ ),
405
+ o
406
+ )
407
+ if (s) await writeCodeFile(resolve(path, 'schemas', `${g}.schema`), s, target)
408
+ }
409
+
410
+ let scopedDefs = Object.entries(endpoints).reduce((p, [_, v]) => {
411
+ let scopeKey = `${v.version ? `/${v.version}` : ''}${v.visibility ? `/${v.visibility}` : ''}${
412
+ v.scope ? `/${v.scope}` : '/main'
413
+ }`
414
+ if (!(scopeKey in p)) p[scopeKey] = []
415
+ p[scopeKey].push(v)
416
+ return p
417
+ }, {}) as Record<string, EndpointEntry[]>
418
+
419
+ for (let [scopeKey, def] of Object.entries(scopedDefs)) {
420
+ let routePath = `routes${scopeKey}.route`
421
+ let schemaPath = `schemas${scopeKey}.schema`
422
+
423
+ let sImports: Record<string, Set<string>> = {}
424
+ let sDecl: string[] = []
425
+
426
+ let rImports: Set<string> = new Set()
427
+ let rDecl: string[] = []
428
+
429
+ for (let d of def) {
430
+ // schema
431
+ Object.entries(d.schema?.imports || {}).forEach(([iK, dep]) => {
432
+ let k = refToPath(dep, dirname(schemaPath))
433
+ if (!k) return
434
+ if (!(k in sImports)) sImports[k] = new Set()
435
+ sImports[k].add(iK)
436
+ })
437
+ sDecl.push(`export const ${d.schema?.name} = ${d.schema?.def}`)
438
+
439
+ // route
440
+ let ep = d.endpoint
441
+ if (!ep) continue
442
+ if (d.schema?.name) rImports.add(d.schema?.name)
443
+ rDecl.push(` ${ep.meta}\ng.${ep.def}`)
444
+ }
445
+
446
+ let schemaFile =
447
+ `import { $T } from 'galbe'\n\n` +
448
+ `${Object.entries(sImports)
449
+ .map(([k, v]) => `import { ${[...v].join(', ')} } from '${k}'`)
450
+ .join('\n')}\n\n` +
451
+ `${sDecl.map(d => d).join('\n\n')}\n`
452
+
453
+ let routeFile =
454
+ `import type { Galbe } from 'galbe'\n` +
455
+ `import { ${[...rImports].join(', ')} } from '../schemas${scopeKey}.schema'\n\n` +
456
+ `export default (g: Galbe) => {\n` +
457
+ rDecl.map(d => d.replaceAll('\n', '\n ')).join('\n\n') +
458
+ `\n}\n`
459
+
460
+ if (sDecl?.length) await writeCodeFile(resolve(path, schemaPath), schemaFile, target)
461
+ if (rDecl?.length) await writeCodeFile(resolve(path, routePath), routeFile, target)
462
+ }
463
+ }
464
+
465
+ export const generateFromOapi = async (
466
+ input: string,
467
+ out: string,
468
+ { version, ext, target }: { version: string; ext: 'json' | 'yaml'; target: 'js' | 'ts' }
469
+ ) => {
470
+ let def: OpenAPIV3.Document = ext === 'json' ? await Bun.file(input).json() : ymlLoad(await Bun.file(input).text())
471
+
472
+ let v = def?.openapi
473
+ if (!v || !semver.satisfies(v, version)) throw new Error('Invalid openapi version')
474
+
475
+ let schemaIndex = buildSchemaIndex(def)
476
+ let endpointDefs = parseEndpoints(def)
477
+
478
+ await writeFiles(out, endpointDefs, schemaIndex, target)
479
+ }
@@ -0,0 +1,75 @@
1
+ import { $ } from 'bun'
2
+ import { devNull } from 'os'
3
+ import { Command, Option } from 'commander'
4
+ import { resolve, relative, extname } from 'path'
5
+ import { rm, exists } from 'fs/promises'
6
+
7
+ import { CWD, fmtList, fmtVal } from '../../util'
8
+ import { generateFromOapi } from './code/openapi.parser'
9
+
10
+ const srcTargets = ['ts', 'js']
11
+ const inputFormats = ['openapi:3.0:yaml', 'openapi:3.0:json']
12
+
13
+ export default (cmd: Command) => {
14
+ cmd
15
+ .description('generate \x1b[1;30m\x1b[36mGalbe\x1b[0m sources')
16
+ .argument('<input>', 'input file')
17
+ .addOption(
18
+ new Option('-f, --format <format>', `input format ${fmtList(inputFormats)}`)
19
+ .argParser(v => {
20
+ if (inputFormats.includes(v)) return v
21
+ console.log(`error: format must be one of ${fmtList(inputFormats)}`)
22
+ process.exit(1)
23
+ })
24
+ .default(null, fmtVal('openapi:3.0:{yaml,json}'))
25
+ )
26
+ .addOption(
27
+ new Option('-t, --target <target>', `source target ${fmtList(srcTargets)}`)
28
+ .argParser(v => {
29
+ if (srcTargets.includes(v)) return v
30
+ console.log(`error: target must be one of ${fmtList(srcTargets)}`)
31
+ process.exit(1)
32
+ })
33
+ .default('ts', fmtVal('ts'))
34
+ )
35
+ .addOption(new Option('-o, --out <dir>', 'output dir').default('src', fmtVal('src')))
36
+ .addOption(new Option('-F, --force', 'force overriding output'))
37
+ .action(async (input, props) => {
38
+ let { format, target, out, force } = props
39
+
40
+ let inputExt = extname(input)
41
+ if (inputExt === '.yml') inputExt = '.yaml'
42
+ if (!['.yaml', '.json'].includes(inputExt)) console.log('error: unknown input extension')
43
+
44
+ if (!format) format = `openapi:3.0:${inputExt.slice(1)}`
45
+
46
+ if ((await exists(resolve(CWD, out))) && !force) {
47
+ console.log(
48
+ `error: output directory ${fmtVal(
49
+ out
50
+ )} already exists. If you're sure you want to override its content, please remove it before or use the ${fmtVal(
51
+ '-F --force'
52
+ )} option`
53
+ )
54
+ process.exit(1)
55
+ }
56
+
57
+ await rm(resolve(CWD, out), { recursive: true })
58
+
59
+ process.stdout.write('💻 \x1b[1;30mGenerating \x1b[36mGalbe\x1b[0m\x1b[1;30m sources\x1b[0m')
60
+ try {
61
+ let match = format.match(/^([^:]*):([^:]*):(.*)$/)
62
+ if (!match) throw new Error(`error: invalid format ${format}`)
63
+ let [_, kind, version, ext] = match
64
+ if (kind === 'openapi') {
65
+ await generateFromOapi(relative(CWD, input), resolve(CWD, out), { version, ext, target })
66
+ } else throw new Error('error: unknown format')
67
+
68
+ await $`bunx prettier --write "${resolve(CWD, out)}/**/*.{js,ts}" > ${devNull} && printf "\u200B"`
69
+ } catch (err) {
70
+ console.log(`error: ${err.message}`)
71
+ process.exit(1)
72
+ }
73
+ process.stdout.write(' : \x1b[1;30m\x1b[32mdone\x1b[0m\n')
74
+ })
75
+ }
@@ -0,0 +1,12 @@
1
+ import { Command } from 'commander'
2
+
3
+ import client from './client'
4
+ import spec from './spec'
5
+ import code from './code'
6
+
7
+ export default (cmd: Command) => {
8
+ cmd.description('generate util')
9
+ client(cmd.command('client'))
10
+ spec(cmd.command('spec'))
11
+ code(cmd.command('code'))
12
+ }
@@ -0,0 +1,94 @@
1
+ import { Command, Option } from 'commander'
2
+ import { resolve, relative, extname } from 'path'
3
+ import { dump as ymlDump, load as ymlLoad } from 'js-yaml'
4
+ import { CWD, fmtList, instanciateRoutes, silentExec, softMerge } from '../../util'
5
+ import { Galbe } from '../../../src'
6
+ import { OpenAPISerializer } from '../../../src/extras'
7
+
8
+ const specTargets = ['openapi:3.0:json', 'openapi:3.0:yaml']
9
+ const parsePckgAuthoRgx = /^\s*([^<(]*)(?:<([^>]+)>)?\s*(?:\(([^)]*)\))?\s*$/
10
+
11
+ const parseAuthor = (author: { name: string; url: string; email: string } | string) => {
12
+ if (!author) return undefined
13
+ if (typeof author === 'string') {
14
+ let m = author.match(parsePckgAuthoRgx)
15
+ if (!m) return undefined
16
+ const [_, name, email, url] = m
17
+ return { name: name?.trim(), email: email?.trim(), url: url?.trim() }
18
+ }
19
+ return author
20
+ }
21
+
22
+ export default (cmd: Command) => {
23
+ cmd
24
+ .description('generate API specification from a \x1b[1;30m\x1b[36mGalbe\x1b[0m instance')
25
+ .argument('<index>', 'index file')
26
+ .addOption(
27
+ new Option('-t, --target <target>', `spec target ${fmtList(specTargets)}`)
28
+ .argParser(v => {
29
+ if (specTargets.includes(v)) return v
30
+ console.log(`error: target must be one of ${fmtList(specTargets)}`)
31
+ process.exit(1)
32
+ })
33
+ .default('openapi:3.0:yaml', 'openapi:3.0:yaml')
34
+ )
35
+ .addOption(new Option('-b, --base <file>', 'base file'))
36
+ .addOption(
37
+ new Option('-o, --out <file>', 'output file').default(undefined, fmtList(['spec/api.yaml', 'spec/api.json']))
38
+ )
39
+ .action(async (index, props) => {
40
+ let { target, out, base } = props
41
+ const [tName, _tVersion, tFormat] = target.split(':')
42
+ if (!out) out = `spec/api.${tFormat}`
43
+
44
+ let baseSpec = {}
45
+ if (base) {
46
+ let ext = extname(base)
47
+ let rd = (str: string) =>
48
+ ext === '.json' ? JSON.parse(str) : ['.yml', '.yaml'].includes(ext) ? ymlLoad(str) : null
49
+ baseSpec = rd(await Bun.file(relative(CWD, base)).text())
50
+ }
51
+
52
+ let pckg: any = {}
53
+ try {
54
+ pckg = await Bun.file(resolve(CWD, 'package.json')).json()
55
+ } catch (e) {}
56
+
57
+ process.stdout.write(`📖 \x1b[1;30mGenerating ${target.split(':')?.[0]} spec\x1b[0m`)
58
+
59
+ let error = null
60
+ let g: Galbe = await silentExec(async () => {
61
+ try {
62
+ const g = (await import(resolve(CWD, index))).default
63
+ await instanciateRoutes(g)
64
+ await g.init()
65
+ return g
66
+ } catch (err) {
67
+ error = err
68
+ }
69
+ })
70
+ if (error) {
71
+ console.log(`\nerror: galbe instance import failed`)
72
+ console.log(error)
73
+ return process.exit(1)
74
+ }
75
+
76
+ if (tName === 'openapi') {
77
+ let openapiSpec = await OpenAPISerializer(g)
78
+ openapiSpec = {
79
+ ...openapiSpec,
80
+ info: {
81
+ title: pckg?.name || 'Galbe app',
82
+ description: pckg?.description,
83
+ contact: parseAuthor(pckg.author),
84
+ //license: TODO
85
+ version: pckg?.version || '0.1.0'
86
+ }
87
+ }
88
+ openapiSpec = softMerge(openapiSpec, baseSpec)
89
+ Bun.write(resolve(CWD, out), tFormat === 'json' ? JSON.stringify(openapiSpec, null, 2) : ymlDump(openapiSpec))
90
+ }
91
+
92
+ process.stdout.write(' : \x1b[1;30m\x1b[32mdone\x1b[0m\n')
93
+ })
94
+ }