galbe 0.13.1 → 0.15.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/README.md +18 -1
- package/bin/commands/build.ts +12 -6
- package/bin/commands/generate/cli/index.ts +163 -0
- package/bin/commands/generate/cli/targets/cac.ts +660 -0
- package/bin/commands/generate/client.ts +522 -189
- package/bin/commands/generate/code/openapi.parser.ts +428 -133
- package/bin/commands/generate/code/route-merge.ts +248 -0
- package/bin/commands/generate/code.ts +110 -23
- package/bin/commands/generate/index.ts +2 -0
- package/bin/commands/generate/model.ts +2 -2
- package/bin/res/client.runtime.ts +185 -0
- package/bin/res/client.template.ts +1 -1
- package/package.json +4 -1
- package/src/cookies.ts +87 -0
- package/src/extras/spec/openapi.serializer.ts +236 -101
- package/src/extras.ts +1 -0
- package/src/index.ts +4 -7
- package/src/parser.ts +88 -65
- package/src/router.ts +34 -20
- package/src/routes.ts +14 -10
- package/src/schema.ts +82 -95
- package/src/server.ts +36 -22
- package/src/types.ts +203 -61
- package/src/util.ts +10 -18
- package/src/validator.ts +62 -32
- package/bin/res/cli.template.js +0 -122
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import ts from 'typescript'
|
|
2
|
+
import type { RoutePlanEntry, ScopePlan } from './openapi.parser'
|
|
3
|
+
|
|
4
|
+
const METHODS = new Set(['get', 'put', 'patch', 'post', 'delete', 'options', 'head'])
|
|
5
|
+
|
|
6
|
+
export type RouteId = string
|
|
7
|
+
|
|
8
|
+
export const routeId = (method: string, path: string): RouteId => `${method.toUpperCase()} ${path}`
|
|
9
|
+
|
|
10
|
+
export type MergeOptions = {
|
|
11
|
+
/** Delete routes that exist in code but are absent from the plan. Default false (kept as `stale`). */
|
|
12
|
+
removeStale?: boolean
|
|
13
|
+
/** Existing route ids (`${METHOD} ${path}`) to leave alone even if absent from plan. */
|
|
14
|
+
ignore?: Set<RouteId>
|
|
15
|
+
/** Existing-route id -> new id. Applied before diffing so a renamed route is treated as an update. */
|
|
16
|
+
rename?: Map<RouteId, RouteId>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type MergeResult = {
|
|
20
|
+
content: string
|
|
21
|
+
added: RouteId[]
|
|
22
|
+
updated: RouteId[]
|
|
23
|
+
removed: RouteId[]
|
|
24
|
+
/** Stale: in code but not in plan, neither removed nor explicitly ignored. */
|
|
25
|
+
stale: RouteId[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type ExistingRoute = {
|
|
29
|
+
origId: RouteId
|
|
30
|
+
method: string
|
|
31
|
+
path: string
|
|
32
|
+
stmt: ts.ExpressionStatement
|
|
33
|
+
pathArg: ts.StringLiteral
|
|
34
|
+
schemaArg: ts.Identifier
|
|
35
|
+
jsdoc: ts.CommentRange | null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type ExistingFile = {
|
|
39
|
+
text: string
|
|
40
|
+
sourceFile: ts.SourceFile
|
|
41
|
+
routes: ExistingRoute[]
|
|
42
|
+
schemaImport: ts.ImportDeclaration | null
|
|
43
|
+
bodyOpenBracePos: number | null
|
|
44
|
+
bodyCloseBracePos: number | null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const importPathForScope = (scopeKey: string): string => {
|
|
48
|
+
const deepness = scopeKey.split('/').length - 1
|
|
49
|
+
return `${Array(deepness).fill('../').join('')}schemas${scopeKey}.schema`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const renderFreshRouteFile = (scope: ScopePlan): string => {
|
|
53
|
+
const importPath = importPathForScope(scope.scopeKey)
|
|
54
|
+
const rDecl = scope.routes.map(r => ` ${r.meta}\ng.${r.call}`)
|
|
55
|
+
return (
|
|
56
|
+
`import { NotImplementedError, type Galbe } from 'galbe'\n` +
|
|
57
|
+
`import { ${[...scope.routeSchemaImports].sort().join(', ')} } from '${importPath}'\n\n` +
|
|
58
|
+
`export default (g: Galbe) => {\n` +
|
|
59
|
+
rDecl.map(d => d.replaceAll('\n', '\n ')).join('\n\n') +
|
|
60
|
+
`\n}\n`
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const findLeadingJsDoc = (sf: ts.SourceFile, stmt: ts.Node): ts.CommentRange | null => {
|
|
65
|
+
const ranges = ts.getLeadingCommentRanges(sf.text, stmt.pos) || []
|
|
66
|
+
let last: ts.CommentRange | null = null
|
|
67
|
+
for (const r of ranges) {
|
|
68
|
+
if (r.kind !== ts.SyntaxKind.MultiLineCommentTrivia) continue
|
|
69
|
+
const txt = sf.text.slice(r.pos, r.end)
|
|
70
|
+
if (txt.startsWith('/**')) last = r
|
|
71
|
+
}
|
|
72
|
+
return last
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const parseExistingFile = (text: string, scope: ScopePlan): ExistingFile => {
|
|
76
|
+
const sf = ts.createSourceFile('route.ts', text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
|
|
77
|
+
|
|
78
|
+
const expectedSuffix = `schemas${scope.scopeKey}.schema`
|
|
79
|
+
let schemaImport: ts.ImportDeclaration | null = null
|
|
80
|
+
let body: ts.Block | null = null
|
|
81
|
+
|
|
82
|
+
for (const stmt of sf.statements) {
|
|
83
|
+
if (ts.isImportDeclaration(stmt)) {
|
|
84
|
+
const spec = stmt.moduleSpecifier
|
|
85
|
+
if (ts.isStringLiteral(spec) && spec.text.endsWith(expectedSuffix)) schemaImport = stmt
|
|
86
|
+
} else if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) {
|
|
87
|
+
const expr = stmt.expression
|
|
88
|
+
if (ts.isArrowFunction(expr) && ts.isBlock(expr.body)) body = expr.body
|
|
89
|
+
else if (ts.isFunctionExpression(expr)) body = expr.body
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const routes: ExistingRoute[] = []
|
|
94
|
+
if (body) {
|
|
95
|
+
for (const stmt of body.statements) {
|
|
96
|
+
if (!ts.isExpressionStatement(stmt)) continue
|
|
97
|
+
const call = stmt.expression
|
|
98
|
+
if (!ts.isCallExpression(call)) continue
|
|
99
|
+
const fn = call.expression
|
|
100
|
+
if (!ts.isPropertyAccessExpression(fn)) continue
|
|
101
|
+
if (!ts.isIdentifier(fn.expression) || fn.expression.text !== 'g') continue
|
|
102
|
+
if (!ts.isIdentifier(fn.name)) continue
|
|
103
|
+
const method = fn.name.text.toLowerCase()
|
|
104
|
+
if (!METHODS.has(method)) continue
|
|
105
|
+
const args = call.arguments
|
|
106
|
+
if (args.length < 2) continue
|
|
107
|
+
const pathArg = args[0]
|
|
108
|
+
const schemaArg = args[1]
|
|
109
|
+
if (!ts.isStringLiteral(pathArg)) continue
|
|
110
|
+
if (!ts.isIdentifier(schemaArg)) continue
|
|
111
|
+
|
|
112
|
+
routes.push({
|
|
113
|
+
origId: routeId(method, pathArg.text),
|
|
114
|
+
method,
|
|
115
|
+
path: pathArg.text,
|
|
116
|
+
stmt,
|
|
117
|
+
pathArg,
|
|
118
|
+
schemaArg,
|
|
119
|
+
jsdoc: findLeadingJsDoc(sf, stmt),
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
text,
|
|
126
|
+
sourceFile: sf,
|
|
127
|
+
routes,
|
|
128
|
+
schemaImport,
|
|
129
|
+
bodyOpenBracePos: body ? body.getStart(sf) : null,
|
|
130
|
+
bodyCloseBracePos: body ? body.end - 1 : null,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
type Edit = { pos: number; end: number; text: string }
|
|
135
|
+
|
|
136
|
+
const applyEdits = (text: string, edits: Edit[]): string => {
|
|
137
|
+
// Apply in reverse order of pos so earlier offsets stay valid.
|
|
138
|
+
const sorted = [...edits].sort((a, b) => b.pos - a.pos || b.end - a.end)
|
|
139
|
+
let out = text
|
|
140
|
+
for (const e of sorted) out = out.slice(0, e.pos) + e.text + out.slice(e.end)
|
|
141
|
+
return out
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const mergeRouteFile = (
|
|
145
|
+
existing: string | null,
|
|
146
|
+
scope: ScopePlan,
|
|
147
|
+
opts: MergeOptions = {}
|
|
148
|
+
): MergeResult => {
|
|
149
|
+
const removeStale = opts.removeStale ?? false
|
|
150
|
+
const ignore = opts.ignore ?? new Set<RouteId>()
|
|
151
|
+
const rename = opts.rename ?? new Map<RouteId, RouteId>()
|
|
152
|
+
|
|
153
|
+
const planById = new Map<RouteId, RoutePlanEntry>()
|
|
154
|
+
for (const r of scope.routes) planById.set(routeId(r.method, r.path), r)
|
|
155
|
+
|
|
156
|
+
if (existing === null || existing.trim() === '') {
|
|
157
|
+
return {
|
|
158
|
+
content: renderFreshRouteFile(scope),
|
|
159
|
+
added: [...planById.keys()],
|
|
160
|
+
updated: [],
|
|
161
|
+
removed: [],
|
|
162
|
+
stale: [],
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const file = parseExistingFile(existing, scope)
|
|
167
|
+
const sf = file.sourceFile
|
|
168
|
+
|
|
169
|
+
const existingByEffectiveId = new Map<RouteId, ExistingRoute>()
|
|
170
|
+
for (const er of file.routes) {
|
|
171
|
+
const effId = rename.get(er.origId) ?? er.origId
|
|
172
|
+
existingByEffectiveId.set(effId, er)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const updates: { er: ExistingRoute; entry: RoutePlanEntry }[] = []
|
|
176
|
+
const removals: ExistingRoute[] = []
|
|
177
|
+
const stale: ExistingRoute[] = []
|
|
178
|
+
|
|
179
|
+
for (const er of file.routes) {
|
|
180
|
+
const effId = rename.get(er.origId) ?? er.origId
|
|
181
|
+
const planEntry = planById.get(effId)
|
|
182
|
+
if (planEntry) updates.push({ er, entry: planEntry })
|
|
183
|
+
else if (ignore.has(er.origId)) stale.push(er)
|
|
184
|
+
else if (removeStale) removals.push(er)
|
|
185
|
+
else stale.push(er)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const additions: RoutePlanEntry[] = []
|
|
189
|
+
for (const [id, entry] of planById) {
|
|
190
|
+
if (!existingByEffectiveId.has(id)) additions.push(entry)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Compute final imported schema names.
|
|
194
|
+
const finalImports = new Set<string>()
|
|
195
|
+
for (const { entry } of updates) finalImports.add(entry.schemaName)
|
|
196
|
+
for (const er of stale) finalImports.add(er.schemaArg.text)
|
|
197
|
+
for (const entry of additions) finalImports.add(entry.schemaName)
|
|
198
|
+
|
|
199
|
+
const edits: Edit[] = []
|
|
200
|
+
|
|
201
|
+
for (const { er, entry } of updates) {
|
|
202
|
+
if (er.path !== entry.path) {
|
|
203
|
+
edits.push({ pos: er.pathArg.getStart(sf), end: er.pathArg.end, text: JSON.stringify(entry.path) })
|
|
204
|
+
}
|
|
205
|
+
if (er.schemaArg.text !== entry.schemaName) {
|
|
206
|
+
edits.push({ pos: er.schemaArg.getStart(sf), end: er.schemaArg.end, text: entry.schemaName })
|
|
207
|
+
}
|
|
208
|
+
if (er.jsdoc) {
|
|
209
|
+
edits.push({ pos: er.jsdoc.pos, end: er.jsdoc.end, text: entry.meta })
|
|
210
|
+
} else if (entry.meta) {
|
|
211
|
+
const stmtStart = er.stmt.getStart(sf)
|
|
212
|
+
edits.push({ pos: stmtStart, end: stmtStart, text: `${entry.meta}\n ` })
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for (const er of removals) {
|
|
217
|
+
edits.push({ pos: er.stmt.pos, end: er.stmt.end, text: '' })
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (file.schemaImport) {
|
|
221
|
+
const sortedImports = [...finalImports].sort()
|
|
222
|
+
if (sortedImports.length === 0) {
|
|
223
|
+
edits.push({ pos: file.schemaImport.pos, end: file.schemaImport.end, text: '' })
|
|
224
|
+
} else {
|
|
225
|
+
const importPath = importPathForScope(scope.scopeKey)
|
|
226
|
+
const newText = `import { ${sortedImports.join(', ')} } from '${importPath}'`
|
|
227
|
+
edits.push({
|
|
228
|
+
pos: file.schemaImport.getStart(sf),
|
|
229
|
+
end: file.schemaImport.end,
|
|
230
|
+
text: newText,
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (additions.length > 0 && file.bodyCloseBracePos !== null) {
|
|
236
|
+
const block = additions.map(a => `\n ${a.meta}\n ${`g.${a.call}`.replaceAll('\n', '\n ')}\n`).join('')
|
|
237
|
+
edits.push({ pos: file.bodyCloseBracePos, end: file.bodyCloseBracePos, text: block })
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
content: applyEdits(existing, edits),
|
|
242
|
+
added: additions.map(a => routeId(a.method, a.path)),
|
|
243
|
+
updated: updates.map(u => routeId(u.entry.method, u.entry.path)),
|
|
244
|
+
removed: removals.map(r => r.origId),
|
|
245
|
+
stale: stale.map(s => s.origId),
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
@@ -2,14 +2,45 @@ import { $ } from 'bun'
|
|
|
2
2
|
import { devNull } from 'os'
|
|
3
3
|
import { Command, Option } from 'commander'
|
|
4
4
|
import { resolve, relative, extname } from 'path'
|
|
5
|
-
import {
|
|
5
|
+
import { exists, readFile } from 'fs/promises'
|
|
6
6
|
|
|
7
7
|
import { CWD, fmtList, fmtVal } from '../../util'
|
|
8
|
-
import {
|
|
8
|
+
import { applyPlan, planFromOapi, type GenerationPlan } from './code/openapi.parser'
|
|
9
|
+
import { mergeRouteFile, type MergeOptions, type RouteId } from './code/route-merge'
|
|
9
10
|
|
|
10
11
|
const srcTargets = ['ts', 'js']
|
|
11
12
|
const inputFormats = ['openapi:3.0:yaml', 'openapi:3.0:json']
|
|
12
13
|
|
|
14
|
+
const collectFlag = (value: string, prev: string[] = []) => [...prev, value]
|
|
15
|
+
|
|
16
|
+
const parseRename = (raw: string): [RouteId, RouteId] => {
|
|
17
|
+
const eq = raw.indexOf('=')
|
|
18
|
+
if (eq < 0) throw new Error(`invalid --rename value ${fmtVal(raw)} (expected "OLD=NEW")`)
|
|
19
|
+
const from = raw.slice(0, eq).trim()
|
|
20
|
+
const to = raw.slice(eq + 1).trim()
|
|
21
|
+
if (!from || !to) throw new Error(`invalid --rename value ${fmtVal(raw)}`)
|
|
22
|
+
return [from, to]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type ScopedDiff = { scope: string; id: RouteId }
|
|
26
|
+
|
|
27
|
+
const printDiff = (diff: {
|
|
28
|
+
added: ScopedDiff[]
|
|
29
|
+
updated: ScopedDiff[]
|
|
30
|
+
removed: ScopedDiff[]
|
|
31
|
+
stale: ScopedDiff[]
|
|
32
|
+
}) => {
|
|
33
|
+
const out = (label: string, prefix: string, items: ScopedDiff[]) => {
|
|
34
|
+
if (!items.length) return
|
|
35
|
+
console.log(`${label} (${items.length}):`)
|
|
36
|
+
for (const x of items) console.log(` ${prefix} ${x.id} \x1b[2m(scope: ${x.scope})\x1b[0m`)
|
|
37
|
+
}
|
|
38
|
+
out('Added', '\x1b[32m+\x1b[0m', diff.added)
|
|
39
|
+
out('Updated', '\x1b[33m~\x1b[0m', diff.updated)
|
|
40
|
+
out('Removed', '\x1b[31m-\x1b[0m', diff.removed)
|
|
41
|
+
out('Stale (kept in code)', '\x1b[2m·\x1b[0m', diff.stale)
|
|
42
|
+
}
|
|
43
|
+
|
|
13
44
|
export default (cmd: Command) => {
|
|
14
45
|
cmd
|
|
15
46
|
.description('generate \x1b[1;30m\x1b[36mGalbe\x1b[0m sources')
|
|
@@ -33,9 +64,20 @@ export default (cmd: Command) => {
|
|
|
33
64
|
.default('ts', fmtVal('ts'))
|
|
34
65
|
)
|
|
35
66
|
.addOption(new Option('-o, --out <dir>', 'output dir').default('src', fmtVal('src')))
|
|
36
|
-
.addOption(new Option('-
|
|
67
|
+
.addOption(new Option('-n, --dry-run', 'show planned changes without writing'))
|
|
68
|
+
.addOption(new Option('--remove-stale', 'delete routes present in code but absent from spec'))
|
|
69
|
+
.addOption(
|
|
70
|
+
new Option('--rename <pair>', '"OLD=NEW" preserve handler when route id changes (repeatable)')
|
|
71
|
+
.argParser(collectFlag)
|
|
72
|
+
.default([])
|
|
73
|
+
)
|
|
74
|
+
.addOption(
|
|
75
|
+
new Option('--ignore-route <route>', '"METHOD /path" leave alone if absent from spec (repeatable)')
|
|
76
|
+
.argParser(collectFlag)
|
|
77
|
+
.default([])
|
|
78
|
+
)
|
|
37
79
|
.action(async (input, props) => {
|
|
38
|
-
let { format, target, out,
|
|
80
|
+
let { format, target, out, dryRun, removeStale, rename, ignoreRoute } = props
|
|
39
81
|
|
|
40
82
|
let inputExt = extname(input)
|
|
41
83
|
if (inputExt === '.yml') inputExt = '.yaml'
|
|
@@ -43,34 +85,79 @@ export default (cmd: Command) => {
|
|
|
43
85
|
|
|
44
86
|
if (!format) format = `openapi:3.0:${inputExt.slice(1)}`
|
|
45
87
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
88
|
+
let renameMap: Map<RouteId, RouteId>
|
|
89
|
+
try {
|
|
90
|
+
renameMap = new Map<RouteId, RouteId>((rename as string[]).map(parseRename))
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.log(`error: ${(err as Error).message}`)
|
|
93
|
+
process.exit(1)
|
|
94
|
+
}
|
|
95
|
+
const ignoreSet = new Set<RouteId>((ignoreRoute as string[]).map(s => s.trim()))
|
|
96
|
+
const mergeOpts: MergeOptions = {
|
|
97
|
+
removeStale: !!removeStale,
|
|
98
|
+
rename: renameMap,
|
|
99
|
+
ignore: ignoreSet,
|
|
58
100
|
}
|
|
59
101
|
|
|
60
|
-
|
|
102
|
+
let plan: GenerationPlan
|
|
61
103
|
try {
|
|
62
104
|
let match = format.match(/^([^:]*):([^:]*):(.*)$/)
|
|
63
|
-
if (!match) throw new Error(`
|
|
105
|
+
if (!match) throw new Error(`invalid format ${format}`)
|
|
64
106
|
let [_, kind, version, ext] = match
|
|
65
|
-
if (kind
|
|
66
|
-
|
|
67
|
-
|
|
107
|
+
if (kind !== 'openapi') throw new Error('unknown format')
|
|
108
|
+
plan = await planFromOapi(relative(CWD, input), { version, ext, target })
|
|
109
|
+
} catch (err) {
|
|
110
|
+
console.log(`error: ${(err as Error).message}`)
|
|
111
|
+
process.exit(1)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const outDir = resolve(CWD, out)
|
|
115
|
+
|
|
116
|
+
// Compute per-scope merge result.
|
|
117
|
+
const mergeResults: { scopeKey: string; content: string; added: RouteId[]; updated: RouteId[]; removed: RouteId[]; stale: RouteId[] }[] = []
|
|
118
|
+
for (const scope of plan.scopes) {
|
|
119
|
+
const routePath = resolve(outDir, `${scope.routeFile}.${target}`)
|
|
120
|
+
const existing = (await exists(routePath)) ? await readFile(routePath, 'utf-8') : null
|
|
121
|
+
const r = mergeRouteFile(existing, scope, mergeOpts)
|
|
122
|
+
mergeResults.push({ scopeKey: scope.scopeKey, ...r })
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const diff = {
|
|
126
|
+
added: mergeResults.flatMap(r => r.added.map(id => ({ scope: r.scopeKey, id }))),
|
|
127
|
+
updated: mergeResults.flatMap(r => r.updated.map(id => ({ scope: r.scopeKey, id }))),
|
|
128
|
+
removed: mergeResults.flatMap(r => r.removed.map(id => ({ scope: r.scopeKey, id }))),
|
|
129
|
+
stale: mergeResults.flatMap(r => r.stale.map(id => ({ scope: r.scopeKey, id }))),
|
|
130
|
+
}
|
|
68
131
|
|
|
69
|
-
|
|
132
|
+
// Block when stale routes need an explicit decision (ignored ones are already resolved).
|
|
133
|
+
const unresolved = diff.stale.filter(s => !ignoreSet.has(s.id))
|
|
134
|
+
if (unresolved.length > 0 && !removeStale && !dryRun) {
|
|
135
|
+
console.log('The following routes exist in code but are not in the spec:')
|
|
136
|
+
for (const s of unresolved) console.log(` - ${s.id} \x1b[2m(scope: ${s.scope})\x1b[0m`)
|
|
137
|
+
console.log()
|
|
138
|
+
console.log('Re-run with one of:')
|
|
139
|
+
console.log(` ${fmtVal('--rename "OLD=NEW"')} treat as a rename, preserve handler`)
|
|
140
|
+
console.log(` ${fmtVal('--ignore-route "ROUTE"')} leave alone, keep as user-managed`)
|
|
141
|
+
console.log(` ${fmtVal('--remove-stale')} confirm deletion of stale routes`)
|
|
142
|
+
process.exit(1)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (dryRun) {
|
|
146
|
+
console.log('\x1b[1;30mDry run — no files will be written.\x1b[0m')
|
|
147
|
+
printDiff(diff)
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
Bun.write(Bun.stdout, '💻 \x1b[1;30mGenerating \x1b[36mGalbe\x1b[0m\x1b[1;30m sources\x1b[0m')
|
|
152
|
+
try {
|
|
153
|
+
const routeContents = new Map(mergeResults.map(r => [r.scopeKey, r.content]))
|
|
154
|
+
await applyPlan(plan, outDir, { routeContents })
|
|
155
|
+
await $`bunx prettier --write "${outDir}/**/*.{js,ts}" > ${devNull} && printf ""`
|
|
70
156
|
} catch (err) {
|
|
71
|
-
console.log(`error: ${err.message}`)
|
|
157
|
+
console.log(`error: ${(err as Error).message}`)
|
|
72
158
|
process.exit(1)
|
|
73
159
|
}
|
|
74
160
|
Bun.write(Bun.stdout, ' : \x1b[1;30m\x1b[32mdone\x1b[0m\n')
|
|
161
|
+
printDiff(diff)
|
|
75
162
|
})
|
|
76
163
|
}
|
|
@@ -4,6 +4,7 @@ import client from './client'
|
|
|
4
4
|
import spec from './spec'
|
|
5
5
|
import code from './code'
|
|
6
6
|
import model from './model'
|
|
7
|
+
import cli from './cli/index'
|
|
7
8
|
|
|
8
9
|
export default (cmd: Command) => {
|
|
9
10
|
cmd.description('generate util')
|
|
@@ -11,4 +12,5 @@ export default (cmd: Command) => {
|
|
|
11
12
|
client(cmd.command('client'))
|
|
12
13
|
code(cmd.command('code'))
|
|
13
14
|
model(cmd.command('model'))
|
|
15
|
+
cli(cmd.command('cli'))
|
|
14
16
|
}
|
|
@@ -125,7 +125,7 @@ export default (cmd: Command) => {
|
|
|
125
125
|
FROM information_schema.tables
|
|
126
126
|
WHERE table_schema = '${schema}'
|
|
127
127
|
AND table_type = 'BASE TABLE'`)
|
|
128
|
-
tables = r.map(r => r.table_name)
|
|
128
|
+
tables = r.map((r: Record<string, string>) => r.table_name)
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
for (const tableName of tables) {
|
|
@@ -133,7 +133,7 @@ export default (cmd: Command) => {
|
|
|
133
133
|
FROM information_schema.columns
|
|
134
134
|
WHERE table_schema = '${schema}' AND table_name = '${tableName}'`)
|
|
135
135
|
types[tableName] = `type ${toPascalCase(tableName)} = {\n${t
|
|
136
|
-
.map(r => ` ${r.column_name}: ${TYPE_MAP?.[r.data_type] ?? 'any'}${r.is_nullable ? ' | null' : ''}`)
|
|
136
|
+
.map((r: Record<string, string>) => ` ${r.column_name}: ${TYPE_MAP?.[r.data_type] ?? 'any'}${r.is_nullable ? ' | null' : ''}`)
|
|
137
137
|
.join(';\n')}\n}`
|
|
138
138
|
}
|
|
139
139
|
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Inlined into the generated client — no exports
|
|
2
|
+
|
|
3
|
+
type GalbeClientConfig = {
|
|
4
|
+
server?: { url?: string }
|
|
5
|
+
headers?: Record<string, string>
|
|
6
|
+
fetch?: (req: Request) => Promise<Response>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
class GalbeClientError extends Error {
|
|
10
|
+
readonly status: number
|
|
11
|
+
readonly headers: Headers
|
|
12
|
+
readonly body: string
|
|
13
|
+
constructor(status: number, headers: Headers, body: string) {
|
|
14
|
+
super(`HTTP ${status}`)
|
|
15
|
+
this.name = 'GalbeClientError'
|
|
16
|
+
this.status = status
|
|
17
|
+
this.headers = headers
|
|
18
|
+
this.body = body
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const _parseResponse = async (res: Response): Promise<any> => {
|
|
23
|
+
const ct = res.headers.get('content-type') ?? ''
|
|
24
|
+
if (ct.includes('application/json')) return res.json()
|
|
25
|
+
if (ct.includes('application/octet-stream')) return new Uint8Array(await res.arrayBuffer())
|
|
26
|
+
return res.text()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const _buildUrl = (base: string | undefined, path: string, query?: Record<string, any>): string => {
|
|
30
|
+
let url = `${base ?? ''}${path}`
|
|
31
|
+
if (query) {
|
|
32
|
+
const params = new URLSearchParams()
|
|
33
|
+
for (const [k, v] of Object.entries(query)) {
|
|
34
|
+
if (v === undefined || v === null) continue
|
|
35
|
+
if (Array.isArray(v)) for (const item of v) params.append(k, String(item))
|
|
36
|
+
else params.set(k, String(v))
|
|
37
|
+
}
|
|
38
|
+
const qs = params.toString()
|
|
39
|
+
if (qs) url += `?${qs}`
|
|
40
|
+
}
|
|
41
|
+
return url
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const _formdata = (data: Record<string, string | string[] | Blob>): FormData => {
|
|
45
|
+
const form = new FormData()
|
|
46
|
+
for (const [k, v] of Object.entries(data)) {
|
|
47
|
+
if (Array.isArray(v)) for (const item of v) form.append(k, String(item))
|
|
48
|
+
else form.append(k, v instanceof Blob ? v : String(v))
|
|
49
|
+
}
|
|
50
|
+
return form
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type _RequestOptions = {
|
|
54
|
+
query?: Record<string, any>
|
|
55
|
+
headers?: Record<string, string>
|
|
56
|
+
contentType?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const _doFetch = (
|
|
60
|
+
config: GalbeClientConfig,
|
|
61
|
+
method: string,
|
|
62
|
+
path: string,
|
|
63
|
+
body?: any,
|
|
64
|
+
options?: _RequestOptions
|
|
65
|
+
): Promise<Response> => {
|
|
66
|
+
const url = _buildUrl(config.server?.url, path, options?.query)
|
|
67
|
+
let bodyInit: BodyInit | undefined
|
|
68
|
+
const bodyHeaders: Record<string, string> = {}
|
|
69
|
+
|
|
70
|
+
if (body !== undefined && body !== null) {
|
|
71
|
+
const ct = options?.contentType
|
|
72
|
+
if (ct === 'urlForm') {
|
|
73
|
+
bodyInit = new URLSearchParams(body).toString()
|
|
74
|
+
bodyHeaders['content-type'] = 'application/x-www-form-urlencoded'
|
|
75
|
+
} else if (ct === 'multipart') {
|
|
76
|
+
bodyInit = _formdata(body)
|
|
77
|
+
} else if (ct === 'byteArray' || body instanceof Uint8Array) {
|
|
78
|
+
bodyInit = body
|
|
79
|
+
bodyHeaders['content-type'] = 'application/octet-stream'
|
|
80
|
+
} else if (ct === 'text') {
|
|
81
|
+
bodyInit = String(body)
|
|
82
|
+
bodyHeaders['content-type'] = 'text/plain'
|
|
83
|
+
} else {
|
|
84
|
+
bodyInit = JSON.stringify(body)
|
|
85
|
+
bodyHeaders['content-type'] = 'application/json'
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const req = new Request(url, {
|
|
90
|
+
method,
|
|
91
|
+
headers: { ...config.headers, ...bodyHeaders, ...options?.headers },
|
|
92
|
+
...(bodyInit !== undefined ? { body: bodyInit } : {}),
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
return (config.fetch ?? fetch)(req)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
class GalbeRequest<T, E = any> {
|
|
99
|
+
#promise: Promise<[Response, Response]>
|
|
100
|
+
#main?: Promise<T>
|
|
101
|
+
|
|
102
|
+
constructor(fetchPromise: Promise<Response>) {
|
|
103
|
+
this.#promise = fetchPromise.then(res => [res, res.clone()] as [Response, Response])
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#getMain(): Promise<T> {
|
|
107
|
+
if (!this.#main) {
|
|
108
|
+
this.#main = this.#promise.then(async ([res]) => {
|
|
109
|
+
if (!res.ok) throw new GalbeClientError(res.status, res.headers, await res.text())
|
|
110
|
+
return _parseResponse(res) as T
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
return this.#main
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
then<R1 = T, R2 = never>(
|
|
117
|
+
onfulfilled?: ((value: T) => R1 | PromiseLike<R1>) | null,
|
|
118
|
+
onrejected?: ((reason: any) => R2 | PromiseLike<R2>) | null
|
|
119
|
+
): Promise<R1 | R2> {
|
|
120
|
+
return this.#getMain().then(onfulfilled, onrejected)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
catch<R = never>(
|
|
124
|
+
onrejected?: ((reason: any) => R | PromiseLike<R>) | null
|
|
125
|
+
): Promise<T | R> {
|
|
126
|
+
return this.#getMain().then(undefined, onrejected)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
finally(onfinally?: (() => void) | null): Promise<T> {
|
|
130
|
+
return this.#getMain().finally(onfinally)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async safe(): Promise<{ ok: true; data: T } | { ok: false; error: E }> {
|
|
134
|
+
const [mainRes, cloneRes] = await this.#promise
|
|
135
|
+
if (mainRes.ok) {
|
|
136
|
+
return { ok: true, data: (await _parseResponse(cloneRes)) as T }
|
|
137
|
+
} else {
|
|
138
|
+
const body = await _parseResponse(cloneRes)
|
|
139
|
+
return { ok: false, error: { status: mainRes.status, headers: mainRes.headers, body } as E }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const _createRequest = <T, E = any>(
|
|
145
|
+
config: GalbeClientConfig,
|
|
146
|
+
method: string,
|
|
147
|
+
path: string,
|
|
148
|
+
body?: any,
|
|
149
|
+
options?: _RequestOptions
|
|
150
|
+
): GalbeRequest<T, E> => new GalbeRequest<T, E>(_doFetch(config, method, path, body, options))
|
|
151
|
+
|
|
152
|
+
const _createRawRequest = async (
|
|
153
|
+
config: GalbeClientConfig,
|
|
154
|
+
method: string,
|
|
155
|
+
path: string,
|
|
156
|
+
body?: any,
|
|
157
|
+
options?: _RequestOptions
|
|
158
|
+
): Promise<any> => {
|
|
159
|
+
const res = await _doFetch(config, method, path, body, options)
|
|
160
|
+
return {
|
|
161
|
+
status: res.status,
|
|
162
|
+
ok: res.ok,
|
|
163
|
+
redirected: res.redirected,
|
|
164
|
+
statusText: res.statusText,
|
|
165
|
+
type: res.type,
|
|
166
|
+
url: res.url,
|
|
167
|
+
headers: res.headers,
|
|
168
|
+
body: {
|
|
169
|
+
json: () => res.json(),
|
|
170
|
+
text: () => res.text(),
|
|
171
|
+
byteArray: () => res.arrayBuffer().then((b: ArrayBuffer) => new Uint8Array(b)),
|
|
172
|
+
stream: (): AsyncGenerator<Uint8Array, void, unknown> => {
|
|
173
|
+
const reader = res.body?.getReader()
|
|
174
|
+
return (async function* () {
|
|
175
|
+
if (!reader) return
|
|
176
|
+
while (true) {
|
|
177
|
+
const { value, done } = await reader.read()
|
|
178
|
+
if (done) break
|
|
179
|
+
yield value!
|
|
180
|
+
}
|
|
181
|
+
})()
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -110,7 +110,7 @@ export default class GalbeClient {
|
|
|
110
110
|
text: 'text/plain',
|
|
111
111
|
json: 'application/json',
|
|
112
112
|
urlForm: 'application/x-www-form-urlencoded',
|
|
113
|
-
}[options.contentType],
|
|
113
|
+
}[options.contentType as 'byteArray' | 'text' | 'json' | 'urlForm'],
|
|
114
114
|
}
|
|
115
115
|
: {}),
|
|
116
116
|
...(options?.headers || {}),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "galbe",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Fast, lightweight and highly customizable JavaScript web framework based on Bun",
|
|
5
5
|
"author": "Pierre Caillaud M (https://github.com/pierre-cm)",
|
|
6
6
|
"type": "module",
|
|
@@ -61,6 +61,9 @@
|
|
|
61
61
|
"github": {
|
|
62
62
|
"requireBranch": "main",
|
|
63
63
|
"release": "true"
|
|
64
|
+
},
|
|
65
|
+
"npm": {
|
|
66
|
+
"publish": false
|
|
64
67
|
}
|
|
65
68
|
}
|
|
66
69
|
}
|