galbe 0.14.0 → 0.15.1
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/bin/commands/build.ts +10 -8
- package/bin/commands/generate/cli/index.ts +22 -15
- package/bin/commands/generate/cli/targets/cac.ts +189 -64
- package/bin/commands/generate/client.ts +520 -110
- package/bin/commands/generate/code.ts +11 -3
- package/bin/commands/generate/model.ts +2 -2
- package/bin/res/client.runtime.ts +184 -0
- package/bin/res/client.template.ts +1 -1
- package/package.json +2 -3
- package/src/extras.ts +1 -1
- package/src/index.ts +10 -10
- package/src/server.ts +1 -1
- package/src/types.ts +28 -4
- package/scripts/hooks/prepare-commit-msg +0 -10
- package/scripts/postinstall.ts +0 -9
|
@@ -1,11 +1,394 @@
|
|
|
1
|
-
import { Script, createContext } from 'vm'
|
|
2
1
|
import { Command, Option } from 'commander'
|
|
3
|
-
import { resolve
|
|
2
|
+
import { resolve } from 'path'
|
|
4
3
|
import { transformSync } from '@swc/core'
|
|
5
4
|
import { CWD, fmtList, instanciateRoutes, silentExec } from '../../util'
|
|
6
|
-
import {
|
|
5
|
+
import { Galbe, type GalbeClientRoute, type GalbeClientOptions } from '../../../src'
|
|
7
6
|
import { walkRoutes } from '../../../src/util'
|
|
8
|
-
import { schemaToTypeStr, STSchema } from '../../../src/schema'
|
|
7
|
+
import { schemaToTypeStr, Kind, Optional, Stream, type STSchema } from '../../../src/schema'
|
|
8
|
+
import type { STResponse, STResponseEntry, STResponseContent } from '../../../src/types'
|
|
9
|
+
|
|
10
|
+
// ─── MIME / short-name helpers ───────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
const MIME_SHORT: Record<string, string> = {
|
|
13
|
+
'application/json': 'json',
|
|
14
|
+
'application/x-www-form-urlencoded': 'urlForm',
|
|
15
|
+
'multipart/form-data': 'multipart',
|
|
16
|
+
'application/octet-stream': 'byteArray',
|
|
17
|
+
'text/plain': 'text',
|
|
18
|
+
'text/html': 'text',
|
|
19
|
+
'*/*': 'raw',
|
|
20
|
+
}
|
|
21
|
+
const SHORT_SUFFIX: Record<string, string> = {
|
|
22
|
+
json: 'Json',
|
|
23
|
+
urlForm: 'UrlForm',
|
|
24
|
+
multipart: 'Multipart',
|
|
25
|
+
byteArray: 'ByteArray',
|
|
26
|
+
text: 'Text',
|
|
27
|
+
raw: 'Raw',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const mimeToShort = (mime: string) => MIME_SHORT[mime] ?? (mime.startsWith('text/') ? 'text' : 'raw')
|
|
31
|
+
|
|
32
|
+
// ─── Operaion-id derivation ───────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
const deriveOperationId = (method: string, path: string): string =>
|
|
35
|
+
`${method}-${path
|
|
36
|
+
.replace(/\//g, '-')
|
|
37
|
+
.replace(/:/g, '')
|
|
38
|
+
.replace(/^-/, '')
|
|
39
|
+
.replace(/-+/g, '-')
|
|
40
|
+
.replace(/-$/, '')}`
|
|
41
|
+
|
|
42
|
+
// ─── Response-entry helpers ───────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
const isResponseValue = (entry: STResponseEntry): boolean =>
|
|
45
|
+
Kind in (entry as any) && typeof (entry as any)[Kind] === 'string'
|
|
46
|
+
|
|
47
|
+
type BodyInfo = { methods: string[]; typeStr: string }
|
|
48
|
+
|
|
49
|
+
const responseBodyInfo = (entry: STResponseEntry): BodyInfo => {
|
|
50
|
+
if (isResponseValue(entry)) {
|
|
51
|
+
const s = entry as STSchema
|
|
52
|
+
const kind = s[Kind]
|
|
53
|
+
const isStream = Stream in s && (s as any)[Stream]
|
|
54
|
+
if (isStream) return { methods: ['stream'], typeStr: schemaToTypeStr(s) }
|
|
55
|
+
if (kind === 'byteArray') return { methods: ['byteArray'], typeStr: 'Uint8Array' }
|
|
56
|
+
if (kind === 'string') return { methods: ['text'], typeStr: 'string' }
|
|
57
|
+
if (kind === 'null') return { methods: [], typeStr: 'null' }
|
|
58
|
+
return { methods: ['json'], typeStr: schemaToTypeStr(s) }
|
|
59
|
+
}
|
|
60
|
+
// STResponseContent
|
|
61
|
+
const content = entry as STResponseContent
|
|
62
|
+
const methods: string[] = []
|
|
63
|
+
let typeStr = 'unknown'
|
|
64
|
+
for (const [mime, s] of Object.entries(content)) {
|
|
65
|
+
if (!mime.includes('/') || !s) continue
|
|
66
|
+
const schema = s as STSchema
|
|
67
|
+
if (Stream in schema && (schema as any)[Stream]) { methods.push('stream'); continue }
|
|
68
|
+
const short = mimeToShort(mime)
|
|
69
|
+
methods.push(short === 'byteArray' ? 'byteArray' : short === 'text' ? 'text' : 'json')
|
|
70
|
+
typeStr = schemaToTypeStr(schema)
|
|
71
|
+
}
|
|
72
|
+
return { methods: [...new Set(methods)], typeStr }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const responseHeadersType = (entry: STResponseEntry): string => {
|
|
76
|
+
const rh: Record<string, any> | undefined = isResponseValue(entry)
|
|
77
|
+
? (entry as any).responseHeaders
|
|
78
|
+
: (entry as STResponseContent).responseHeaders
|
|
79
|
+
if (!rh || !Object.keys(rh).length) return 'Headers'
|
|
80
|
+
const keys = Object.keys(rh).map(k => `'${k}'`).join('|')
|
|
81
|
+
return `{get<K extends string>(name:K):K extends ${keys}?string:string|null}&Omit<Headers,'get'>`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── Code-generation helpers ──────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
const OKS = new Set([200, 201, 202, 203, 204, 205, 206, 207, 208, 226])
|
|
87
|
+
// prettier-ignore
|
|
88
|
+
const HTTP_CODES = [100,101,102,103,200,201,202,203,204,205,206,207,208,226,300,301,302,303,304,305,307,308,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,421,422,423,424,426,428,429,431,451,500,501,502,503,504,505,506,507,508,510,511]
|
|
89
|
+
|
|
90
|
+
const STATIC_TYPES = `
|
|
91
|
+
// prettier-ignore
|
|
92
|
+
type _OKStatus = ${[...OKS].join('|')}
|
|
93
|
+
// prettier-ignore
|
|
94
|
+
type _HttpStatus = ${HTTP_CODES.join('|')}
|
|
95
|
+
`.trim()
|
|
96
|
+
|
|
97
|
+
// Convert operationId to a safe TypeScript identifier (for type names)
|
|
98
|
+
const safeTypeId = (id: string) => id.replace(/[^a-zA-Z0-9_$]/g, '_')
|
|
99
|
+
// Quote a property key if it is not a valid bare identifier
|
|
100
|
+
const safePropKey = (id: string) => /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(id) ? id : `'${id}'`
|
|
101
|
+
|
|
102
|
+
const buildBodyObjType = (methods: string[], typeStr: string): string => {
|
|
103
|
+
const parts: string[] = []
|
|
104
|
+
if (methods.includes('json')) parts.push(`json():Promise<${typeStr}>`)
|
|
105
|
+
if (methods.includes('text')) parts.push(`text():Promise<string>`)
|
|
106
|
+
if (methods.includes('byteArray')) parts.push(`byteArray():Promise<Uint8Array>`)
|
|
107
|
+
if (methods.includes('stream')) parts.push(`stream():AsyncGenerator<Uint8Array,void,unknown>`)
|
|
108
|
+
if (!parts.length) parts.push(`text():Promise<string>`)
|
|
109
|
+
return `{${parts.join(';')}}`
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const FALLBACK_BODY = `{json():Promise<unknown>;text():Promise<string>;byteArray():Promise<Uint8Array>;stream():AsyncGenerator<Uint8Array,void,unknown>}`
|
|
113
|
+
|
|
114
|
+
/** Generates the `type _GR_OpId = ...` discriminated union for $raw */
|
|
115
|
+
const buildRawResponseType = (operationId: string, response: STResponse | null): string => {
|
|
116
|
+
const typeName = `_GR_${safeTypeId(operationId)}`
|
|
117
|
+
if (!response || !Object.keys(response).length) {
|
|
118
|
+
return `type ${typeName} = {status:_HttpStatus;ok:boolean;headers:Headers;body:${FALLBACK_BODY}}`
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const declared: string[] = []
|
|
122
|
+
const arms: string[] = []
|
|
123
|
+
|
|
124
|
+
for (const [rawKey, entry] of Object.entries(response)) {
|
|
125
|
+
if (!entry) continue
|
|
126
|
+
const status = rawKey === 'default' ? null : Number(rawKey)
|
|
127
|
+
if (status === null) continue // handled as fallback
|
|
128
|
+
declared.push(String(status))
|
|
129
|
+
const ok = OKS.has(status)
|
|
130
|
+
const { methods, typeStr } = responseBodyInfo(entry)
|
|
131
|
+
const headersT = responseHeadersType(entry)
|
|
132
|
+
arms.push(
|
|
133
|
+
`{status:${status};ok:${ok};headers:${headersT};body:${buildBodyObjType(methods, typeStr)}}`
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// fallback arm
|
|
138
|
+
const defaultEntry = (response as any)['default'] as STResponseEntry | undefined
|
|
139
|
+
const fallbackBody = defaultEntry ? buildBodyObjType(...Object.values(responseBodyInfo(defaultEntry)) as [string[], string]) : FALLBACK_BODY
|
|
140
|
+
const excludeStr = declared.length ? `Exclude<_HttpStatus,${declared.join('|')}>` : '_HttpStatus'
|
|
141
|
+
arms.push(`{status:${excludeStr};ok:boolean;headers:Headers;body:${fallbackBody}}`)
|
|
142
|
+
|
|
143
|
+
return `type ${typeName} = \n | ${arms.join('\n | ')}`
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Build the 2xx union body type (return type of the simple API) */
|
|
147
|
+
const buildSuccessType = (response: STResponse | null): string => {
|
|
148
|
+
if (!response) return 'unknown'
|
|
149
|
+
const types: string[] = []
|
|
150
|
+
for (const [rawKey, entry] of Object.entries(response)) {
|
|
151
|
+
if (!entry) continue
|
|
152
|
+
const status = rawKey === 'default' ? null : Number(rawKey)
|
|
153
|
+
if (status === null || !OKS.has(status)) continue
|
|
154
|
+
const { typeStr } = responseBodyInfo(entry)
|
|
155
|
+
types.push(typeStr)
|
|
156
|
+
}
|
|
157
|
+
return types.length ? [...new Set(types)].join('|') : 'unknown'
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Build the error union type (E param of GalbeRequest) */
|
|
161
|
+
const buildErrorType = (operationId: string, response: STResponse | null): string => {
|
|
162
|
+
const typeName = `_Err_${safeTypeId(operationId)}`
|
|
163
|
+
if (!response || !Object.keys(response).length) {
|
|
164
|
+
return `type ${typeName} = {status:number;headers:Headers;body:any}`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const declared: string[] = []
|
|
168
|
+
const arms: string[] = []
|
|
169
|
+
|
|
170
|
+
for (const [rawKey, entry] of Object.entries(response)) {
|
|
171
|
+
if (!entry) continue
|
|
172
|
+
const status = rawKey === 'default' ? null : Number(rawKey)
|
|
173
|
+
if (status === null || OKS.has(status)) continue
|
|
174
|
+
declared.push(String(status))
|
|
175
|
+
const { typeStr } = responseBodyInfo(entry)
|
|
176
|
+
const headersT = responseHeadersType(entry)
|
|
177
|
+
arms.push(`{status:${status};headers:${headersT};body:${typeStr}}`)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const defaultEntry = (response as any)['default'] as STResponseEntry | undefined
|
|
181
|
+
const fallbackBody = defaultEntry ? responseBodyInfo(defaultEntry).typeStr : 'any'
|
|
182
|
+
// also exclude all declared 2xx
|
|
183
|
+
const declared2xx = Object.keys(response)
|
|
184
|
+
.filter(k => k !== 'default' && OKS.has(Number(k)))
|
|
185
|
+
.map(String)
|
|
186
|
+
const allDeclared = [...declared, ...declared2xx]
|
|
187
|
+
const errExcludeStr = allDeclared.length ? `Exclude<_HttpStatus,${allDeclared.join('|')}>` : '_HttpStatus'
|
|
188
|
+
arms.push(`{status:${errExcludeStr};headers:Headers;body:${fallbackBody}}`)
|
|
189
|
+
|
|
190
|
+
return `type ${typeName} = \n | ${arms.join('\n | ')}`
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** One logical route expanded into 1..N variants (one per body content-type) */
|
|
194
|
+
interface RouteVariant {
|
|
195
|
+
operationId: string // final method name (may include suffix)
|
|
196
|
+
method: string
|
|
197
|
+
path: string
|
|
198
|
+
pathTemplate: string // with ${param} interpolation
|
|
199
|
+
params: { name: string; typeStr: string; description?: string }[]
|
|
200
|
+
bodyShortName: string | null // null = no body
|
|
201
|
+
bodyTypeStr: string | null
|
|
202
|
+
query: Record<string, { typeStr: string; optional: boolean; description?: string }>
|
|
203
|
+
reqHeaders: Record<string, { typeStr: string; optional: boolean; description?: string }>
|
|
204
|
+
response: STResponse | null
|
|
205
|
+
rawTypeName: string
|
|
206
|
+
errTypeName: string
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const expandRoute = (r: GalbeClientRoute): RouteVariant[] => {
|
|
210
|
+
const pathTemplate = r.path.replaceAll(/:([^/]+)/g, '${$1}')
|
|
211
|
+
const params = Object.entries(r.params).map(([name, p]) => ({
|
|
212
|
+
name,
|
|
213
|
+
typeStr: p.type,
|
|
214
|
+
description: p.description,
|
|
215
|
+
}))
|
|
216
|
+
const query = Object.fromEntries(
|
|
217
|
+
Object.entries(r.query).map(([k, v]) => [k, { typeStr: v.type, optional: v.optional, description: v.description }])
|
|
218
|
+
)
|
|
219
|
+
const reqHeaders = Object.fromEntries(
|
|
220
|
+
Object.entries(r.headers).map(([k, v]) => [k, { typeStr: v.type, optional: v.optional, description: v.description }])
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
const bodyEntries = r.body ? Object.entries(r.body) : []
|
|
224
|
+
const multiBody = bodyEntries.length > 1
|
|
225
|
+
|
|
226
|
+
if (!bodyEntries.length) {
|
|
227
|
+
return [
|
|
228
|
+
{
|
|
229
|
+
operationId: r.operationId,
|
|
230
|
+
method: r.method,
|
|
231
|
+
path: r.path,
|
|
232
|
+
pathTemplate,
|
|
233
|
+
params,
|
|
234
|
+
bodyShortName: null,
|
|
235
|
+
bodyTypeStr: null,
|
|
236
|
+
query,
|
|
237
|
+
reqHeaders,
|
|
238
|
+
response: r.response,
|
|
239
|
+
rawTypeName: `_GR_${safeTypeId(r.operationId)}`,
|
|
240
|
+
errTypeName: `_Err_${safeTypeId(r.operationId)}`,
|
|
241
|
+
},
|
|
242
|
+
]
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return bodyEntries.map(([shortName, schema]) => {
|
|
246
|
+
const suffix = multiBody ? (SHORT_SUFFIX[shortName] ?? shortName[0].toUpperCase() + shortName.slice(1)) : ''
|
|
247
|
+
const id = r.operationId + suffix
|
|
248
|
+
return {
|
|
249
|
+
operationId: id,
|
|
250
|
+
method: r.method,
|
|
251
|
+
path: r.path,
|
|
252
|
+
pathTemplate,
|
|
253
|
+
params,
|
|
254
|
+
bodyShortName: shortName,
|
|
255
|
+
bodyTypeStr: schemaToTypeStr(schema),
|
|
256
|
+
query,
|
|
257
|
+
reqHeaders,
|
|
258
|
+
response: r.response,
|
|
259
|
+
rawTypeName: `_GR_${safeTypeId(id)}`,
|
|
260
|
+
errTypeName: `_Err_${safeTypeId(id)}`,
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ─── Method signature builders ────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
const buildParamList = (v: RouteVariant): string => {
|
|
268
|
+
const parts: string[] = []
|
|
269
|
+
for (const p of v.params) parts.push(`${p.name}:${p.typeStr}`)
|
|
270
|
+
if (v.bodyShortName !== null) parts.push(`body:${v.bodyTypeStr}`)
|
|
271
|
+
const queryFields = Object.entries(v.query)
|
|
272
|
+
.map(([k, p]) => `${k}${p.optional ? '?' : ''}:${p.typeStr}`)
|
|
273
|
+
.join(';')
|
|
274
|
+
const headerFields = Object.keys(v.reqHeaders).length
|
|
275
|
+
? Object.entries(v.reqHeaders)
|
|
276
|
+
.map(([k, p]) => `'${k}'${p.optional ? '?' : ''}:${p.typeStr}`)
|
|
277
|
+
.join(';')
|
|
278
|
+
: null
|
|
279
|
+
|
|
280
|
+
const optParts: string[] = []
|
|
281
|
+
if (queryFields) optParts.push(`query?:{${queryFields}}`)
|
|
282
|
+
if (headerFields) optParts.push(`headers?:{${headerFields}}`)
|
|
283
|
+
else optParts.push(`headers?:Record<string,string>`)
|
|
284
|
+
parts.push(`options?:{${optParts.join(';')}}`)
|
|
285
|
+
|
|
286
|
+
return parts.join(',')
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const buildFetchCall = (v: RouteVariant, fnName: string): string => {
|
|
290
|
+
const pathExpr = v.params.length ? `\`${v.pathTemplate}\`` : `'${v.path}'`
|
|
291
|
+
const bodyArg = v.bodyShortName !== null ? 'body' : 'undefined'
|
|
292
|
+
const optionsArg = v.bodyShortName !== null
|
|
293
|
+
? `{...(options??{}),contentType:'${v.bodyShortName}'}`
|
|
294
|
+
: 'options'
|
|
295
|
+
return `${fnName}(this.#config,'${v.method.toUpperCase()}',${pathExpr},${bodyArg},${optionsArg})`
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const buildMethod = (v: RouteVariant): string => {
|
|
299
|
+
const successType = buildSuccessType(v.response)
|
|
300
|
+
const paramList = buildParamList(v)
|
|
301
|
+
const call = buildFetchCall(v, '_createRequest')
|
|
302
|
+
return ` ${safePropKey(v.operationId)}(${paramList}):GalbeRequest<${successType},${v.errTypeName}>{return ${call}}`
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const buildRawDecl = (v: RouteVariant): string => {
|
|
306
|
+
const paramList = buildParamList(v)
|
|
307
|
+
return ` ${safePropKey(v.operationId)}(${paramList}):Promise<${v.rawTypeName}>`
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const buildRawImpl = (v: RouteVariant): string => {
|
|
311
|
+
const paramList = buildParamList(v)
|
|
312
|
+
const call = buildFetchCall(v, '_createRawRequest')
|
|
313
|
+
return ` ${safePropKey(v.operationId)}:(${paramList})=>${call}`
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ─── Top-level code generator ─────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
export const generateClientCode = async (opts: {
|
|
319
|
+
routes: GalbeClientRoute[]
|
|
320
|
+
namedTypes: Record<string, string>
|
|
321
|
+
className: string
|
|
322
|
+
version: string
|
|
323
|
+
runtimeContent: string
|
|
324
|
+
}): Promise<string> => {
|
|
325
|
+
const { routes, namedTypes, className, version, runtimeContent } = opts
|
|
326
|
+
|
|
327
|
+
// Expand each route into per-content-type variants
|
|
328
|
+
const variants = routes.flatMap(expandRoute)
|
|
329
|
+
|
|
330
|
+
// Collect response type declarations (deduplicated by raw/err type name)
|
|
331
|
+
const rawTypeDecls = new Map<string, string>()
|
|
332
|
+
const errTypeDecls = new Map<string, string>()
|
|
333
|
+
for (const r of routes) {
|
|
334
|
+
const baseId = r.operationId
|
|
335
|
+
const bodyEntries = r.body ? Object.entries(r.body) : []
|
|
336
|
+
const multiBody = bodyEntries.length > 1
|
|
337
|
+
|
|
338
|
+
if (!bodyEntries.length) {
|
|
339
|
+
rawTypeDecls.set(`_GR_${safeTypeId(baseId)}`, buildRawResponseType(baseId, r.response))
|
|
340
|
+
errTypeDecls.set(`_Err_${safeTypeId(baseId)}`, buildErrorType(baseId, r.response))
|
|
341
|
+
} else {
|
|
342
|
+
for (const [shortName] of bodyEntries) {
|
|
343
|
+
const suffix = multiBody ? (SHORT_SUFFIX[shortName] ?? '') : ''
|
|
344
|
+
const id = baseId + suffix
|
|
345
|
+
rawTypeDecls.set(`_GR_${safeTypeId(id)}`, buildRawResponseType(id, r.response))
|
|
346
|
+
errTypeDecls.set(`_Err_${safeTypeId(id)}`, buildErrorType(id, r.response))
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const namedTypesStr = Object.entries(namedTypes)
|
|
352
|
+
.map(([name, t]) => `export type ${name} = ${t}`)
|
|
353
|
+
.join('\n')
|
|
354
|
+
|
|
355
|
+
const rawDeclStr = variants.map(buildRawDecl).join('\n')
|
|
356
|
+
const rawImplStr = variants.map(buildRawImpl).join(',\n')
|
|
357
|
+
const methodsStr = variants.map(buildMethod).join('\n')
|
|
358
|
+
|
|
359
|
+
return [
|
|
360
|
+
`// Generated by galbe generate client — v${version}`,
|
|
361
|
+
`// Do not edit manually.`,
|
|
362
|
+
``,
|
|
363
|
+
runtimeContent,
|
|
364
|
+
``,
|
|
365
|
+
STATIC_TYPES,
|
|
366
|
+
``,
|
|
367
|
+
namedTypesStr ? `// Named types\n${namedTypesStr}\n` : '',
|
|
368
|
+
[...rawTypeDecls.values(), ...errTypeDecls.values()].join('\n'),
|
|
369
|
+
``,
|
|
370
|
+
`export class ${className} {`,
|
|
371
|
+
` readonly #config:GalbeClientConfig`,
|
|
372
|
+
` readonly $raw:{`,
|
|
373
|
+
rawDeclStr,
|
|
374
|
+
` }`,
|
|
375
|
+
` constructor(config?:GalbeClientConfig){`,
|
|
376
|
+
` this.#config=config??{}`,
|
|
377
|
+
` this.$raw={`,
|
|
378
|
+
rawImplStr,
|
|
379
|
+
` }`,
|
|
380
|
+
` }`,
|
|
381
|
+
methodsStr,
|
|
382
|
+
`}`,
|
|
383
|
+
`export {GalbeClientError}`,
|
|
384
|
+
`export type {GalbeClientConfig}`,
|
|
385
|
+
`export default ${className}`,
|
|
386
|
+
]
|
|
387
|
+
.filter(s => s !== '')
|
|
388
|
+
.join('\n')
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ─── CLI command ──────────────────────────────────────────────────────────────
|
|
9
392
|
|
|
10
393
|
const clientTargets = ['ts', 'js']
|
|
11
394
|
|
|
@@ -23,139 +406,166 @@ export default (cmd: Command) => {
|
|
|
23
406
|
process.exit(1)
|
|
24
407
|
})
|
|
25
408
|
)
|
|
409
|
+
.addOption(new Option('-c, --config <file>', 'config file (.ts or .js)'))
|
|
26
410
|
.action(async (index, props) => {
|
|
27
|
-
let { target, out } = props
|
|
28
|
-
if (!target) target = clientTargets.includes(
|
|
29
|
-
if (!out) out = { ts: 'dist/client.ts', js: 'dist/client.js' }[target]
|
|
411
|
+
let { target, out, config } = props
|
|
412
|
+
if (!target) target = clientTargets.includes(index.split('.').pop()) ? index.split('.').pop() : 'ts'
|
|
413
|
+
if (!out) out = { ts: 'dist/client.ts', js: 'dist/client.js' }[target as 'ts' | 'js']
|
|
414
|
+
|
|
30
415
|
let pckg: any = {}
|
|
31
|
-
try {
|
|
32
|
-
pckg = await Bun.file(resolve(CWD, 'package.json')).json()
|
|
33
|
-
} catch (e) {}
|
|
416
|
+
try { pckg = await Bun.file(resolve(CWD, 'package.json')).json() } catch {}
|
|
34
417
|
|
|
35
|
-
let error = null
|
|
418
|
+
let error: any = null
|
|
36
419
|
Bun.write(Bun.stdout, '💻 \x1b[1;30mBuilding \x1b[36mGalbe\x1b[0m\x1b[1;30m client\x1b[0m')
|
|
37
|
-
|
|
420
|
+
|
|
421
|
+
const g: Galbe = await silentExec(async () => {
|
|
38
422
|
try {
|
|
39
|
-
const
|
|
40
|
-
await instanciateRoutes(
|
|
41
|
-
await
|
|
42
|
-
return
|
|
43
|
-
} catch (err) {
|
|
44
|
-
error = err
|
|
45
|
-
}
|
|
423
|
+
const mod = (await import(resolve(CWD, index))).default
|
|
424
|
+
await instanciateRoutes(mod)
|
|
425
|
+
await mod.init()
|
|
426
|
+
return mod
|
|
427
|
+
} catch (err) { error = err }
|
|
46
428
|
})
|
|
429
|
+
|
|
47
430
|
if (error) {
|
|
48
431
|
console.log(`\nerror: galbe instance import failed`)
|
|
49
432
|
console.log(error)
|
|
50
433
|
return process.exit(1)
|
|
51
434
|
}
|
|
52
435
|
|
|
53
|
-
const routes: Record<Method, Route[]> = {
|
|
54
|
-
get: [],
|
|
55
|
-
post: [],
|
|
56
|
-
put: [],
|
|
57
|
-
patch: [],
|
|
58
|
-
delete: [],
|
|
59
|
-
options: [],
|
|
60
|
-
head: [],
|
|
61
|
-
}
|
|
62
|
-
const types: Record<string, STResponse> = {}
|
|
63
436
|
const metaRoutes = g.meta?.reduce(
|
|
64
|
-
(
|
|
437
|
+
(acc, c) => ({ ...acc, ...c.routes }),
|
|
65
438
|
{} as Record<string, Record<string, Record<string, any>>>
|
|
66
439
|
)
|
|
67
440
|
|
|
441
|
+
// Collect named types from response schemas with ids
|
|
442
|
+
const namedTypes: Record<string, string> = {}
|
|
443
|
+
|
|
444
|
+
const routes: GalbeClientRoute[] = []
|
|
445
|
+
const autoDerivedIds: string[] = []
|
|
446
|
+
|
|
68
447
|
walkRoutes(g.router.routes, r => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
])
|
|
114
|
-
),
|
|
115
|
-
}
|
|
116
|
-
: {}),
|
|
117
|
-
},
|
|
448
|
+
const meta = metaRoutes?.[r.path]?.[r.method]
|
|
449
|
+
const [, summary, description] = meta?.head?.match(/^([^\n]*)\n\n(.*)/) ?? []
|
|
450
|
+
const explicitId: string | undefined = meta?.operationId
|
|
451
|
+
const autoDerived = !explicitId
|
|
452
|
+
const operationId = explicitId ?? deriveOperationId(r.method, r.path)
|
|
453
|
+
if (autoDerived) autoDerivedIds.push(`${r.method.toUpperCase()} ${r.path} → ${operationId}`)
|
|
454
|
+
|
|
455
|
+
// Collect named types
|
|
456
|
+
Object.values(r.schema.response ?? {}).forEach(entry => {
|
|
457
|
+
if (!entry) return
|
|
458
|
+
const s = isResponseValue(entry as STResponseEntry) ? entry as STSchema : null
|
|
459
|
+
if (s?.id) namedTypes[s.id] = schemaToTypeStr(s)
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
// Build body map (MIME → short name)
|
|
463
|
+
let body: Record<string, STSchema> | null = null
|
|
464
|
+
const rawBody = r.schema.body as any
|
|
465
|
+
if (rawBody && (rawBody as STSchema)[Kind] !== 'null') {
|
|
466
|
+
body = {}
|
|
467
|
+
for (const [mime, s] of Object.entries(rawBody)) {
|
|
468
|
+
if (!mime.includes('/') || !s) continue
|
|
469
|
+
body[mimeToShort(mime)] = s as STSchema
|
|
470
|
+
}
|
|
471
|
+
if (!Object.keys(body).length) body = null
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Build query schema map
|
|
475
|
+
const query: GalbeClientRoute['query'] = {}
|
|
476
|
+
for (const [k, s] of Object.entries((r.schema.query ?? {}) as Record<string, STSchema>)) {
|
|
477
|
+
query[k] = {
|
|
478
|
+
type: schemaToTypeStr({ ...s, [Optional]: false }),
|
|
479
|
+
optional: !!(s as any)[Optional],
|
|
480
|
+
description: (s as any).description,
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Build headers schema map
|
|
485
|
+
const headers: GalbeClientRoute['headers'] = {}
|
|
486
|
+
for (const [k, s] of Object.entries((r.schema.headers ?? {}) as Record<string, STSchema>)) {
|
|
487
|
+
headers[k] = {
|
|
488
|
+
type: schemaToTypeStr({ ...s, [Optional]: false }),
|
|
489
|
+
optional: !!(s as any)[Optional],
|
|
490
|
+
description: (s as any).description,
|
|
491
|
+
}
|
|
118
492
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
493
|
+
|
|
494
|
+
routes.push({
|
|
495
|
+
method: r.method,
|
|
496
|
+
path: r.path,
|
|
497
|
+
operationId,
|
|
498
|
+
autoDerived,
|
|
499
|
+
params: Object.fromEntries(
|
|
500
|
+
[...r.path.matchAll(/:([^/]+)/g)].map(m => {
|
|
501
|
+
const ps = (r.schema.params as Record<string, STSchema>)?.[m[1]]
|
|
502
|
+
return [m[1], { type: ps ? schemaToTypeStr(ps) : 'string', description: (ps as any)?.description }]
|
|
503
|
+
})
|
|
504
|
+
),
|
|
505
|
+
query,
|
|
506
|
+
headers,
|
|
507
|
+
body,
|
|
508
|
+
response: (r.schema.response as STResponse) ?? null,
|
|
509
|
+
summary: summary || undefined,
|
|
510
|
+
description: description || undefined,
|
|
511
|
+
tags: meta?.tags ? (Array.isArray(meta.tags) ? meta.tags : [meta.tags]) : [],
|
|
512
|
+
})
|
|
126
513
|
})
|
|
127
514
|
|
|
128
|
-
|
|
129
|
-
let
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
515
|
+
// Load user config
|
|
516
|
+
let userTransform: ((routes: GalbeClientRoute[]) => GalbeClientRoute[]) | undefined
|
|
517
|
+
let userOptions: GalbeClientOptions | undefined
|
|
518
|
+
if (config) {
|
|
519
|
+
try {
|
|
520
|
+
const m = await import(resolve(CWD, config))
|
|
521
|
+
userTransform = m.transform
|
|
522
|
+
userOptions = m.options
|
|
523
|
+
} catch (err) {
|
|
524
|
+
console.log(`\nerror: config file import failed`)
|
|
525
|
+
console.log(err)
|
|
526
|
+
return process.exit(1)
|
|
137
527
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
528
|
+
}
|
|
529
|
+
const finalRoutes = userTransform ? userTransform(routes) : routes
|
|
530
|
+
const className = userOptions?.className ?? 'Client'
|
|
531
|
+
|
|
532
|
+
// Log generation info
|
|
533
|
+
const allVariants = finalRoutes.flatMap(expandRoute)
|
|
534
|
+
Bun.write(Bun.stdout, '\n')
|
|
535
|
+
for (const v of allVariants) {
|
|
536
|
+
const sourceRoute = finalRoutes.find(r =>
|
|
537
|
+
r.operationId === v.operationId || v.operationId.startsWith(r.operationId)
|
|
538
|
+
)
|
|
539
|
+
const isAuto = sourceRoute?.autoDerived
|
|
540
|
+
Bun.write(
|
|
541
|
+
Bun.stdout,
|
|
542
|
+
` ${isAuto ? '\x1b[33m~\x1b[0m' : '\x1b[32m+\x1b[0m'} ${v.operationId}${isAuto ? ' \x1b[2m(auto-derived)\x1b[0m' : ''}\n`
|
|
543
|
+
)
|
|
544
|
+
}
|
|
545
|
+
if (autoDerivedIds.length) {
|
|
546
|
+
Bun.write(
|
|
547
|
+
Bun.stdout,
|
|
548
|
+
`\n \x1b[33m!\x1b[0m ${autoDerivedIds.length} auto-derived operationId(s) — add explicit operationIds to stabilise names\n`
|
|
549
|
+
)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const runtimeContent = await Bun.file(resolve(import.meta.dir, '..', '..', 'res', 'client.runtime.ts')).text()
|
|
553
|
+
|
|
554
|
+
let code = await generateClientCode({
|
|
555
|
+
routes: finalRoutes,
|
|
556
|
+
namedTypes,
|
|
557
|
+
className,
|
|
558
|
+
version: pckg?.version ?? '0.1.0',
|
|
559
|
+
runtimeContent,
|
|
143
560
|
})
|
|
144
561
|
|
|
145
562
|
if (target === 'js') {
|
|
146
|
-
|
|
147
|
-
jsc: {
|
|
148
|
-
parser: {
|
|
149
|
-
syntax: 'typescript',
|
|
150
|
-
},
|
|
151
|
-
preserveAllComments: true,
|
|
152
|
-
target: 'esnext',
|
|
153
|
-
},
|
|
563
|
+
code = transformSync(code, {
|
|
564
|
+
jsc: { parser: { syntax: 'typescript' }, preserveAllComments: true, target: 'esnext' },
|
|
154
565
|
}).code
|
|
155
566
|
}
|
|
156
567
|
|
|
157
|
-
await Bun.write(out,
|
|
158
|
-
|
|
568
|
+
await Bun.write(out, code)
|
|
159
569
|
Bun.write(Bun.stdout, ' : \x1b[1;30m\x1b[32mdone\x1b[0m\n')
|
|
160
570
|
process.exit(0)
|
|
161
571
|
})
|