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.
@@ -1,26 +1,403 @@
1
- import { $ } from 'bun'
2
- import { devNull } from 'os'
3
- import { Script, createContext } from 'vm'
4
1
  import { Command, Option } from 'commander'
5
- import { resolve, extname } from 'path'
6
- import { rm } from 'fs/promises'
2
+ import { resolve } from 'path'
7
3
  import { transformSync } from '@swc/core'
8
- import { CWD, fmtList, instanciateRoutes, silentExec, abbreviateVar } from '../../util'
9
- import { $T, Galbe, GalbeCLICommand, Method, Route, STResponse } from '../../../src'
4
+ import { CWD, fmtList, instanciateRoutes, silentExec } from '../../util'
5
+ import { Galbe, type GalbeClientRoute, type GalbeClientOptions } from '../../../src'
10
6
  import { walkRoutes } from '../../../src/util'
11
- import { schemaToTypeStr, Optional, STSchema } from '../../../src/schema'
7
+ import { schemaToTypeStr, Kind, Optional, Stream, type STSchema } from '../../../src/schema'
8
+ import type { STResponse, STResponseEntry, STResponseContent } from '../../../src/types'
12
9
 
13
- const clientTargets = ['ts', 'js', 'cli']
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 ──────────────────────────────────────────────────────────────
392
+
393
+ const clientTargets = ['ts', 'js']
14
394
 
15
395
  export default (cmd: Command) => {
16
396
  cmd
17
397
  .description('generate a \x1b[1;30m\x1b[36mGalbe\x1b[0m client')
18
398
  .argument('<index>', 'index file')
19
399
  .addOption(
20
- new Option('-o, --out <file>', 'output file').default(
21
- null,
22
- fmtList(['dist/client.ts', 'dist/client.js', 'dist/cli'])
23
- )
400
+ new Option('-o, --out <file>', 'output file').default(null, fmtList(['dist/client.ts', 'dist/client.js']))
24
401
  )
25
402
  .addOption(
26
403
  new Option('-t, --target <target>', `build target ${fmtList(clientTargets)}`).argParser(v => {
@@ -29,210 +406,166 @@ export default (cmd: Command) => {
29
406
  process.exit(1)
30
407
  })
31
408
  )
409
+ .addOption(new Option('-c, --config <file>', 'config file (.ts or .js)'))
32
410
  .action(async (index, props) => {
33
- let { target, out } = props
34
- if (!target) target = clientTargets.includes(extname(index)?.slice(1)) ? extname(index)?.slice(1) : 'ts'
35
- if (!out) out = { ts: 'dist/client.ts', js: 'dist/client.js', cli: 'dist/cli' }[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
+
36
415
  let pckg: any = {}
37
- try {
38
- pckg = await Bun.file(resolve(CWD, 'package.json')).json()
39
- } catch (e) {}
416
+ try { pckg = await Bun.file(resolve(CWD, 'package.json')).json() } catch {}
40
417
 
41
- let error = null
418
+ let error: any = null
42
419
  Bun.write(Bun.stdout, '💻 \x1b[1;30mBuilding \x1b[36mGalbe\x1b[0m\x1b[1;30m client\x1b[0m')
43
- let g: Galbe = await silentExec(async () => {
420
+
421
+ const g: Galbe = await silentExec(async () => {
44
422
  try {
45
- const g = (await import(resolve(CWD, index))).default
46
- await instanciateRoutes(g)
47
- await g.init()
48
- return g
49
- } catch (err) {
50
- error = err
51
- }
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 }
52
428
  })
429
+
53
430
  if (error) {
54
431
  console.log(`\nerror: galbe instance import failed`)
55
432
  console.log(error)
56
433
  return process.exit(1)
57
434
  }
58
435
 
59
- const routes: Record<Method, Route[]> = {
60
- get: [],
61
- post: [],
62
- put: [],
63
- patch: [],
64
- delete: [],
65
- options: [],
66
- head: [],
67
- }
68
- const types: Record<string, STResponse> = {}
69
- let commands: GalbeCLICommand[] = []
70
436
  const metaRoutes = g.meta?.reduce(
71
- (routes, c) => ({ ...routes, ...c.routes }),
437
+ (acc, c) => ({ ...acc, ...c.routes }),
72
438
  {} as Record<string, Record<string, Record<string, any>>>
73
439
  )
74
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
+
75
447
  walkRoutes(g.router.routes, r => {
76
- let meta = metaRoutes?.[r.path]?.[r.method]
77
- let [_, summary, description] = meta?.head?.match(/^([^\n]*)\n\n(.*)/) || []
78
- if (!summary) description = meta?.head
79
- let route = {
80
- ...r,
81
- ...(meta?.operationId ? { alias: meta?.operationId } : {}),
82
- ...(summary ? { summary } : {}),
83
- ...(description ? { description } : {}),
84
- pathT: r.path.replaceAll(/:([^\/]+)/g, '${$1}'),
85
- params:
86
- Object.fromEntries(
87
- [...r.path.matchAll(/:([^\/]+)/g)]?.map(m => [
88
- m?.[1],
89
- {
90
- ...(r.schema?.params?.[m?.[1]]
91
- ? {
92
- type: schemaToTypeStr(r.schema.params[m[1]]),
93
- ...(r.schema.params[m[1]]?.description
94
- ? { description: r.schema.params[m[1]].description as string }
95
- : {}),
96
- }
97
- : { type: 'string' }),
98
- },
99
- ])
100
- ) || {},
101
- contentTypes: Object.keys(r.schema.body || { default: '' })
102
- .map(s => `'${s}'`)
103
- .join('|'),
104
- schemas: {
105
- ...(r.schema.headers ? { headers: schemaToTypeStr($T.object(r.schema.headers)) } : {}),
106
- ...(r.schema.query ? { query: schemaToTypeStr($T.object(r.schema.query)) } : {}),
107
- ...(r.schema.body
108
- ? {
109
- body: `{${Object.entries(r.schema.body)
110
- .map(([ct, s]) => `${ct}: ${schemaToTypeStr(s)}`)
111
- .join(';')}}`,
112
- }
113
- : {}),
114
- ...(r.schema.response
115
- ? {
116
- response: Object.fromEntries(
117
- Object.entries(r.schema.response).map(([k, v]) => [
118
- k === 'default' ? '"default"' : k,
119
- schemaToTypeStr(v as STSchema),
120
- ])
121
- ),
122
- }
123
- : {}),
124
- },
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
125
472
  }
126
- Object.values(r.schema.response || {})
127
- .filter(s => s?.id)
128
- .forEach(s => {
129
- //@ts-ignore
130
- types[s.id] = schemaToTypeStr(s)
131
- })
132
- routes[r.method.toLocaleLowerCase()].push(route)
133
- if (target === 'cli' && meta?.operationId)
134
- commands.push({
135
- name: meta.operationId,
136
- tags: meta?.tags ? (Array.isArray(meta.tags) ? meta.tags : [meta.tags]) : [],
137
- description: route.summary || route.description,
138
- route,
139
- arguments:
140
- Object.entries((route?.params || {}) as Record<string, { type: string; description?: string }>)?.map(
141
- ([k, p]) => {
142
- return {
143
- name: k,
144
- type: p.type === 'boolean' ? '' : `<${p.type}>`,
145
- description: p?.description || '',
146
- }
147
- }
148
- ) || [],
149
- options:
150
- Object.entries((r.schema?.query || {}) as Record<string, STSchema>)?.map(([k, o]) => {
151
- let type = schemaToTypeStr({ ...o, [Optional]: false })
152
- return {
153
- name: k,
154
- short: abbreviateVar(k),
155
- type: type === 'boolean' ? '' : `<${type}>`,
156
- description: o?.description || '',
157
- default: o.default,
158
- }
159
- }) || [],
160
- })
161
- })
162
473
 
163
- if (target === 'js' || target === 'ts') {
164
- const file = await Bun.file(resolve(import.meta.dir, '..', '..', 'res', 'client.template.ts')).text()
165
- let filled = file.replaceAll(/\/\*\%([\s\S]*?)\%\*\//g, (_match, p) => {
166
- let idt = p.match(/^\n*([ \t]*)/, p)?.[1] || ''
167
- const script = new Script(p)
168
- const sandbox = {
169
- console,
170
- version: pckg?.version || '0.1.0',
171
- routes,
172
- types,
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,
173
481
  }
174
- createContext(sandbox)
175
- let res = script.runInNewContext(sandbox)
176
- if (typeof res === 'string') res = res.split('\n')
177
- if (Array.isArray(res)) return res.map((s, i) => (i === 0 ? s : `${idt}${s}`)).join('\n')
178
- return res ?? ''
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
+ }
492
+ }
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]) : [],
179
512
  })
513
+ })
180
514
 
181
- if (target === 'js') {
182
- filled = transformSync(filled, {
183
- jsc: {
184
- parser: {
185
- syntax: 'typescript',
186
- },
187
- preserveAllComments: true,
188
- target: 'esnext',
189
- },
190
- }).code
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)
191
527
  }
528
+ }
529
+ const finalRoutes = userTransform ? userTransform(routes) : routes
530
+ const className = userOptions?.className ?? 'Client'
192
531
 
193
- await Bun.write(out, filled)
194
- } else if (target === 'cli') {
195
- const file = await Bun.file(resolve(import.meta.dir, '..', '..', 'res', 'cli.template.js')).text()
196
-
197
- // Plugin CLI hook
198
- if (commands) for (let p of g.plugins) if (p.cli) await p.cli(commands)
199
-
200
- const tags = commands.reduce(
201
- (p, c) => {
202
- if (c.tags.length)
203
- for (const t of c.tags) {
204
- if (!(t in p)) p[t] = []
205
- p[t].push(c)
206
- }
207
- else p[''].push(c)
208
- return p
209
- },
210
- { '': [] as GalbeCLICommand[] }
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`
211
549
  )
550
+ }
212
551
 
213
- let filled = file.replaceAll(/\/\*\%([\s\S]*?)\%\*\//g, (_match, p) => {
214
- let idt = p.match(/^\n*([ \t]*)/, p)?.[1] || ''
215
- const script = new Script(p)
216
- const sandbox = {
217
- console,
218
- name: pckg?.name || 'Galbe app CLI',
219
- description: pckg?.description || '',
220
- version: pckg?.version || '0.1.0',
221
- tags,
222
- }
223
- createContext(sandbox)
224
- let res = script.runInNewContext(sandbox)
225
- if (typeof res === 'string') res = res.split('\n')
226
- if (Array.isArray(res)) return res.map((s, i) => (i === 0 ? s : `${idt}${s}`)).join('\n')
227
- return res ?? ''
228
- })
229
- const buildId = crypto.randomUUID()
230
- const buildPath = resolve(CWD, '.galbe', 'client', `${buildId}.js`)
231
- await Bun.write(buildPath, filled)
232
- await $`bun build --compile ${buildPath} --outfile ${resolve(CWD, out)} > ${devNull} && printf "\u200B"`
233
- await rm(resolve(CWD, '.galbe'), { recursive: true })
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,
560
+ })
561
+
562
+ if (target === 'js') {
563
+ code = transformSync(code, {
564
+ jsc: { parser: { syntax: 'typescript' }, preserveAllComments: true, target: 'esnext' },
565
+ }).code
234
566
  }
235
567
 
568
+ await Bun.write(out, code)
236
569
  Bun.write(Bun.stdout, ' : \x1b[1;30m\x1b[32mdone\x1b[0m\n')
237
570
  process.exit(0)
238
571
  })