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.
- package/.github/workflows/build_test.yml +2 -4
- package/.github/workflows/release.yml +2 -2
- package/bin/cli.ts +8 -104
- package/bin/commands/build.ts +95 -0
- package/bin/commands/dev.ts +53 -0
- package/bin/commands/generate/client.ts +192 -0
- package/bin/commands/generate/code/openapi.parser.ts +479 -0
- package/bin/commands/generate/code.ts +75 -0
- package/bin/commands/generate/index.ts +12 -0
- package/bin/commands/generate/spec.ts +94 -0
- package/bin/res/cli.template.js +120 -0
- package/bin/res/client.template.ts +161 -0
- package/bin/util.ts +164 -0
- package/bun.lockb +0 -0
- package/docs/plugins.md +36 -36
- package/docs/routes.md +3 -3
- package/package.json +10 -8
- package/scripts/postinstall.ts +1 -3
- package/src/extras/spec/openapi.serializer.ts +264 -0
- package/src/extras.ts +1 -0
- package/src/index.ts +103 -73
- package/src/parser.ts +27 -5
- package/src/router.ts +27 -29
- package/src/routes.ts +138 -39
- package/src/schema.ts +69 -8
- package/src/server.ts +9 -10
- package/src/types.ts +73 -41
- package/src/util.ts +95 -10
- package/src/validator.ts +2 -0
- package/test/parser.test.ts +34 -0
- package/test/requests.test.ts +5 -12
- package/test/resources/test.route.comment.ts +20 -0
- package/test/responses.test.ts +109 -11
- package/test/routeFiles.test.ts +44 -27
- package/test/router.test.ts +67 -42
- package/scripts/build.ts +0 -14
|
@@ -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
|
+
}
|