galbe 0.13.0 → 0.14.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.
@@ -2,7 +2,6 @@ import { semver } from 'bun'
2
2
  import { transformSync } from '@swc/core'
3
3
  import { resolve, relative, dirname } from 'path'
4
4
  import { OpenAPIV3 } from 'openapi-types'
5
- import { inferBodyType } from '../../../../src/util'
6
5
 
7
6
  type SchemaEntry = {
8
7
  key: string
@@ -10,6 +9,13 @@ type SchemaEntry = {
10
9
  schema: string
11
10
  dependsOn: Set<string>
12
11
  usedBy: Set<string>
12
+ /** Response-only: the original component-level description, kept distinct
13
+ * from the inner schema's own description. */
14
+ responseDescription?: string
15
+ /** Response-only: content-level single example. */
16
+ responseExample?: any
17
+ /** Response-only: content-level multi-key examples. */
18
+ responseExamples?: Record<string, any>
13
19
  }
14
20
  type EndpointEntry = {
15
21
  version?: string
@@ -46,14 +52,15 @@ const refToPath = (ref: string, basePath?: string) => {
46
52
  const orderDeps = (deps: Record<string, SchemaEntry>) => {
47
53
  let stack = Object.keys(deps)
48
54
  let l = new Set<string>()
49
- const ascend = (d: { dependsOn: Set<string> }) => {
50
- for (let p of d.dependsOn.keys()) {
55
+ const ascend = (d: { dependsOn: Set<string> }, visiting: Set<string>) => {
56
+ for (let p of d.dependsOn) {
57
+ if (visiting.has(p) || l.has(p)) continue
51
58
  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
- }
59
+ visiting.add(p)
60
+ ascend(deps[p], visiting)
61
+ l.add(p)
62
+ let idx = stack.indexOf(p)
63
+ if (idx >= 0) stack.splice(idx, 1)
57
64
  }
58
65
  }
59
66
  }
@@ -61,7 +68,7 @@ const orderDeps = (deps: Record<string, SchemaEntry>) => {
61
68
  let k = stack.pop()
62
69
  if (!k) continue
63
70
  let d = deps[k]
64
- ascend(d)
71
+ ascend(d, new Set([k]))
65
72
  if (!l.has(k)) {
66
73
  l.add(k)
67
74
  }
@@ -70,9 +77,12 @@ const orderDeps = (deps: Record<string, SchemaEntry>) => {
70
77
  }
71
78
  const serialize = (obj: any) => {
72
79
  return JSON.stringify(obj, (k, value) => {
73
- if (k === 'pattern' && value) return `/${value}/`
80
+ if (k === 'pattern' && value) return `__PATTERN__${value}__ENDPATTERN__`
74
81
  return value
75
- }).replace(/"\/(.*)\/([gimsuy]*)"/g, '/$1/$2')
82
+ }).replace(/"__PATTERN__([\s\S]*?)__ENDPATTERN__"/g, (_, body) => {
83
+ const decoded = JSON.parse(`"${body}"`) as string
84
+ return `/${decoded.replace(/\//g, '\\/')}/`
85
+ })
76
86
  }
77
87
 
78
88
  const writeCodeFile = async (path: string, content: string, target: 'js' | 'ts') => {
@@ -110,17 +120,22 @@ const parseOapiSchema = (
110
120
  minLength?: number
111
121
  maxLength?: number
112
122
  pattern?: string
123
+ format?: string
113
124
  minItems?: number
114
125
  maxItems?: number
115
126
  unique?: boolean
127
+ default?: any
128
+ examples?: any
116
129
  } = {
117
130
  ...details,
118
131
  title: os.title,
119
132
  description: details.description || os.description,
133
+ default: os.default,
134
+ ...(os.example !== undefined ? { examples: os.example } : {}),
120
135
  }
121
136
 
122
137
  let resp = ''
123
- let hasOptions = Object.values(options).some(v => !!v)
138
+ let hasOptions = Object.values(options).some(v => v !== undefined)
124
139
  let optArg = hasOptions ? serialize(options) : ''
125
140
  let anyOf = os.oneOf || os.anyOf
126
141
  let allOf = os.allOf
@@ -139,9 +154,8 @@ const parseOapiSchema = (
139
154
  } else if (anyOf?.length) {
140
155
  if (anyOf.length === 1) resp = parseOapiSchema(anyOf[0] as OpenAPIV3.SchemaObject, details, extra)
141
156
  else {
142
- resp = `$T.union([${anyOf.map(s => parseOapiSchema(s as OpenAPIV3.SchemaObject)).join(',')}], ${serialize(
143
- options
144
- )})`
157
+ const builder = os.oneOf ? '$T.oneOf' : '$T.anyOf'
158
+ resp = `${builder}([${anyOf.map(s => parseOapiSchema(s as OpenAPIV3.SchemaObject)).join(',')}]${optArg ? `, ${optArg}` : ''})`
145
159
  }
146
160
  } else if (allOf?.length) {
147
161
  if (allOf.length === 1) resp = parseOapiSchema(allOf[0] as OpenAPIV3.SchemaObject, details, extra)
@@ -151,7 +165,7 @@ const parseOapiSchema = (
151
165
  )})`
152
166
  }
153
167
  } else if (!os?.type) {
154
- return `$T.any(${details && Object.keys(details).length ? JSON.stringify(details) : ''})`
168
+ return `$T.any(${hasOptions ? optArg : ''})`
155
169
  } else if (os.type === 'boolean') resp = `$T.boolean(${hasOptions ? serialize(options) : ''})`
156
170
  else if (os.type === 'number') {
157
171
  let { max, min, exclusiveMax, exclusiveMin } = {
@@ -161,7 +175,7 @@ const parseOapiSchema = (
161
175
  exclusiveMin: os.minimum !== undefined && os.exclusiveMinimum ? os.minimum : undefined,
162
176
  }
163
177
  options = { ...options, min, max, exclusiveMax, exclusiveMin }
164
- hasOptions = Object.values(options).some(v => !!v)
178
+ hasOptions = Object.values(options).some(v => v !== undefined)
165
179
  resp = `$T.number(${hasOptions ? serialize(options) : ''})`
166
180
  } else if (os.type === 'integer') {
167
181
  let max = os.maximum !== undefined && !os.exclusiveMaximum ? os.maximum : undefined
@@ -169,35 +183,43 @@ const parseOapiSchema = (
169
183
  let exclusiveMax = os.maximum !== undefined && os.exclusiveMaximum ? os.maximum : undefined
170
184
  let exclusiveMin = os.minimum !== undefined && os.exclusiveMinimum ? os.minimum : undefined
171
185
  options = { ...options, min, max, exclusiveMax, exclusiveMin }
172
- hasOptions = Object.values(options).some(v => !!v)
186
+ hasOptions = Object.values(options).some(v => v !== undefined)
173
187
  resp = `$T.integer(${hasOptions ? serialize(options) : ''})`
174
188
  } else if (os.type === 'string') {
175
189
  if (os.format === 'binary') resp = `$T.byteArray(${hasOptions ? serialize(options) : ''})`
176
190
  else if (os.enum?.length === 1) {
177
191
  resp = `$T.literal("${os.enum[0]}")`
178
192
  } else if (os.enum?.length) {
179
- resp = `$T.union([${os.enum.map(v => `$T.literal("${v}")`).join(', ')}])`
193
+ const literals = os.enum.map(v => `$T.literal("${v}")`).join(', ')
194
+ resp = `$T.union([${literals}]${optArg ? `, ${optArg}` : ''})`
180
195
  } else {
181
196
  let minLength = os.minLength
182
197
  let maxLength = os.maxLength
183
198
  let pattern = os.pattern
184
- options = { ...options, minLength, maxLength, pattern }
185
- hasOptions = Object.values(options).some(v => !!v)
199
+ let format = os.format
200
+ options = { ...options, minLength, maxLength, pattern, format }
201
+ hasOptions = Object.values(options).some(v => v !== undefined)
186
202
  resp = `$T.string(${hasOptions ? serialize(options) : ''})`
187
203
  }
188
204
  } else if (os.type === 'array') {
189
- let minItems = os.minItems
190
- let maxItems = os.maxItems
205
+ // Galbe's ArrayOptions uses minLength/maxLength/unique (mirroring the
206
+ // builder API), not OpenAPI's minItems/maxItems/uniqueItems names.
207
+ let minLength = os.minItems
208
+ let maxLength = os.maxItems
191
209
  let unique = os.uniqueItems
192
- options = { ...options, minItems, maxItems, unique }
193
- resp = `$T.array(${parseOapiSchema(os?.items)}, ${optArg})`
210
+ options = { ...options, minLength, maxLength, unique }
211
+ hasOptions = Object.values(options).some(v => v !== undefined)
212
+ optArg = hasOptions ? serialize(options) : ''
213
+ resp = `$T.array(${parseOapiSchema(os?.items)}${optArg ? `, ${optArg}` : ''})`
194
214
  } else if (os.type === 'object') {
215
+ let required = new Set(os.required || [])
195
216
  let props = Object.entries(os?.properties || {})
196
217
  .map(([k, v]) => {
197
218
  v = v as OpenAPIV3.SchemaObject
219
+ const isRequired = required.has(k)
198
220
  const w = (s: string) => {
199
- if (!v.required && v.nullable) return `$T.nullish(${s})`
200
- else if (!v.required) return `$T.optional(${s})`
221
+ if (!isRequired && v.nullable) return `$T.nullish(${s})`
222
+ else if (!isRequired) return `$T.optional(${s})`
201
223
  else if (v.nullable) return `$T.nullable(${s})`
202
224
  return s
203
225
  }
@@ -222,21 +244,48 @@ const buildSchemaIndex = (def: OpenAPIV3.Document) => {
222
244
  const initSchema = (k: string, s: any, kind: 'schemas' | 'requestBodies' | 'responses') => {
223
245
  let schema = ''
224
246
  let dependsOn = new Set<string>()
247
+ let responseExample: any = undefined
248
+ let responseExamples: Record<string, any> | undefined = undefined
225
249
  if (kind === 'schemas') schema = parseOapiSchema(s, { id: k })
226
- else if (kind === 'requestBodies' || kind === 'responses') {
250
+ else if (kind === 'requestBodies') {
227
251
  let schemas = [] as string[]
228
252
  if (!!s.content) {
229
- schemas = [
230
- ...new Set(
231
- Object.entries((s as OpenAPIV3.RequestBodyObject)?.content || { null: {} }).map(([media, v]) => {
232
- return parseOapiSchema(v.schema, { id: k }, { media })
233
- })
234
- ),
235
- ]
253
+ const contentMap = (s as OpenAPIV3.RequestBodyObject)?.content || { null: {} }
254
+ schemas = [...new Set(Object.entries(contentMap).map(([media, v]) => parseOapiSchema(v.schema, { id: k }, { media })))]
236
255
  } else {
237
256
  schemas = [parseOapiSchema(undefined, { id: k, ...s })]
238
257
  }
239
258
  schema = schemas.length <= 0 ? '' : schemas.length === 1 ? schemas[0] : `$T.union([${schemas.join(',')}])`
259
+ } else if (kind === 'responses') {
260
+ if (!s.content) {
261
+ schema = parseOapiSchema(undefined, { id: k, ...s })
262
+ } else {
263
+ const contentMap = s.content as Record<string, { schema?: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject; example?: any; examples?: any }>
264
+ const entries = Object.entries(contentMap)
265
+ for (const [, v] of entries) {
266
+ if (v.example !== undefined && responseExample === undefined) responseExample = v.example
267
+ if (v.examples && Object.keys(v.examples).length) responseExamples = { ...(responseExamples || {}), ...v.examples }
268
+ }
269
+ const keyGroups: Record<string, string[]> = {}
270
+ for (const [media, v] of entries) {
271
+ const galbeKey = media
272
+ const schemaStr = parseOapiSchema(v.schema, {}, { media })
273
+ if (!keyGroups[galbeKey]) keyGroups[galbeKey] = []
274
+ keyGroups[galbeKey].push(schemaStr)
275
+ }
276
+ const uniqueKeys = Object.keys(keyGroups)
277
+ if (uniqueKeys.length <= 1) {
278
+ const [key] = uniqueKeys
279
+ const uniqueSchemas = [...new Set(keyGroups[key] || [])]
280
+ schema = uniqueSchemas.length === 0 ? '' : uniqueSchemas.length === 1 ? uniqueSchemas[0] : `$T.union([${uniqueSchemas.join(',')}])`
281
+ } else {
282
+ const parts = Object.entries(keyGroups).map(([k, schemas]) => {
283
+ const unique = [...new Set(schemas)]
284
+ return `"${k}": ${unique.length === 1 ? unique[0] : `$T.union([${unique.join(',')}])`}`
285
+ })
286
+ schema = `{${parts.join(',')}}`
287
+ }
288
+ }
240
289
  }
241
290
  schema = unref(schema, m => {
242
291
  let l = m.split('/')
@@ -249,6 +298,11 @@ const buildSchemaIndex = (def: OpenAPIV3.Document) => {
249
298
  schema,
250
299
  dependsOn,
251
300
  usedBy: new Set(),
301
+ ...(kind === 'responses' && typeof s?.description === 'string' && s.description
302
+ ? { responseDescription: s.description }
303
+ : {}),
304
+ ...(kind === 'responses' && responseExample !== undefined ? { responseExample } : {}),
305
+ ...(kind === 'responses' && responseExamples ? { responseExamples } : {}),
252
306
  }
253
307
  }
254
308
  for (let [k, v] of Object.entries(def.components?.schemas || {})) initSchema(k, v, 'schemas')
@@ -256,35 +310,77 @@ const buildSchemaIndex = (def: OpenAPIV3.Document) => {
256
310
  for (let [k, v] of Object.entries(def.components?.responses || {})) initSchema(k, v, 'responses')
257
311
 
258
312
  Object.entries(index).forEach(([k, v]) => {
259
- for (let d of v.dependsOn) index[d].usedBy.add(k)
313
+ for (let d of v.dependsOn) index[d]?.usedBy.add(k)
260
314
  })
261
315
 
262
316
  return index
263
317
  }
264
318
 
265
- const parseEndpointDef = (method: string, path: string, def?: OpenAPIV3.OperationObject) => {
319
+ const resolveParamRef = (
320
+ ref: string,
321
+ components: OpenAPIV3.ComponentsObject | undefined
322
+ ): OpenAPIV3.ParameterObject | undefined => {
323
+ let match = ref.match(/^#\/components\/parameters\/(.+)$/)
324
+ if (!match) return undefined
325
+ let target = components?.parameters?.[match[1]]
326
+ if (!target) return undefined
327
+ if ('$ref' in target) return resolveParamRef(target.$ref, components)
328
+ return target
329
+ }
330
+
331
+ const parseEndpointDef = (
332
+ method: string,
333
+ path: string,
334
+ def?: OpenAPIV3.OperationObject,
335
+ components?: OpenAPIV3.ComponentsObject
336
+ ) => {
266
337
  if (!def) return {}
267
- let imports = {}
338
+ let imports: Record<string, string> = {}
268
339
  let p = path.replaceAll(/\{([^\}]*)\}/g, ':$1')
269
340
  // let description = def.summary || def.description
270
- let pathName = path.replaceAll(/\/\{[^\}]*\}/g, 'X').replaceAll(/[^$\w\d_]+([$\w\d_])/g, (_, $1) => $1.toUpperCase())
271
- let schemaName = `${method}${pathName}`.replace(/^\w/, c => c.toUpperCase())
341
+ let schemaName = def.operationId
342
+ ? def.operationId.replace(/^\w/, c => c.toUpperCase())
343
+ : `${method}${path
344
+ .replaceAll(/\{([^\}]+)\}/g, (_, p) => `By${p.replace(/^\w/, (c: string) => c.toUpperCase())}`)
345
+ .replaceAll(/[^$\w\d_]+([$\w\d_])/g, (_, $1) => $1.toUpperCase())
346
+ }`.replace(/^\w/, c => c.toUpperCase())
272
347
 
273
348
  let meta = '/**\n'
274
349
  if (def.summary) meta += ` * ${def.summary}\n *\n`
275
350
  if (def.description) meta += ` * ${def.description.replace(/\n/g, '\n * ')}\n`
276
351
  if (def.operationId) meta += ` * @operationId ${def.operationId}\n`
277
- if (def.externalDocs) meta += ` * @externalDocs ${def.externalDocs}\n`
352
+ if (def.externalDocs?.url) meta += ` * @externalDocs ${def.externalDocs.url}\n`
278
353
  if (def.tags) meta += ` * @tags ${def.tags.join(' ')}\n`
279
- if (def.deprecated) meta == ' * @deprecated\n'
354
+ if (Array.isArray(def.security)) {
355
+ if (def.security.length === 0) {
356
+ meta += ` * @security none\n`
357
+ } else {
358
+ for (const s of def.security) {
359
+ const keys = Object.keys(s)
360
+ if (keys.length === 0) {
361
+ meta += ` * @security none\n`
362
+ } else {
363
+ for (const k of keys) {
364
+ const scopes = (s as any)[k] as string[]
365
+ meta += ` * @security ${k}${scopes && scopes.length ? ` ${scopes.join(' ')}` : ''}\n`
366
+ }
367
+ }
368
+ }
369
+ }
370
+ }
371
+ if (def.deprecated) meta += ' * @deprecated\n'
280
372
  meta += ' */'
281
373
  let endpoint = `${method}("${p}", ${schemaName}, ctx => {\n throw new NotImplementedError()\n})`
282
374
 
283
- let sp = { path: {}, query: {}, header: {}, body: {}, formData: {} } // TODO handle body and formData cases
375
+ let sp: Record<string, Record<string, string>> = { path: {}, query: {}, header: {}, body: {}, formData: {} } // TODO handle body and formData cases
376
+
284
377
  for (let _p of def?.parameters || []) {
285
- // @ts-ignore: TODO handle refs cases
286
- if (_p.$ref) continue
287
- let p = _p as OpenAPIV3.ParameterObject
378
+ let p: OpenAPIV3.ParameterObject | undefined
379
+ if ('$ref' in _p) {
380
+ p = resolveParamRef(_p.$ref, components)
381
+ if (!p) continue
382
+ } else p = _p as OpenAPIV3.ParameterObject
383
+ if (!sp[p.in]) continue
288
384
  let o = (s: string) => {
289
385
  const [_, so] = [...(s.match(/^\$T.optional\((.*)\)$/) || [])]
290
386
  s = so ?? s
@@ -335,7 +431,7 @@ const parseEndpointDef = (method: string, path: string, def?: OpenAPIV3.Operatio
335
431
  ])
336
432
  ),
337
433
  ]
338
- body = bs.length ? ` body: {${bs.map(([k, v]) => `"${inferBodyType(k)}":${o(v)}`).join(',')}}` : ''
434
+ body = bs.length ? ` body: {${bs.map(([k, v]) => `"${k}":${o(v)}`).join(',')}}` : ''
339
435
  }
340
436
  }
341
437
 
@@ -343,7 +439,6 @@ const parseEndpointDef = (method: string, path: string, def?: OpenAPIV3.Operatio
343
439
  let r = def?.responses
344
440
  let rs = Object.fromEntries(
345
441
  Object.entries(r || {}).map(([status, sv]) => {
346
- let entries: string[] = []
347
442
  let s: string = Number.isInteger(Number(status)) ? status : 'default'
348
443
 
349
444
  //@ts-ignore
@@ -354,27 +449,91 @@ const parseEndpointDef = (method: string, path: string, def?: OpenAPIV3.Operatio
354
449
  return l[l.length - 1]
355
450
  })
356
451
  : null
357
- if (rootRef) {
358
- return [s, [rootRef]]
359
- }
360
-
361
- for (let [_type, tv] of Object.entries((sv as OpenAPIV3.ResponseObject)?.content || {})) {
362
- entries.push(
363
- unref(parseOapiSchema(tv.schema), m => {
452
+ if (rootRef) return [s, rootRef]
453
+
454
+ const respObj = sv as OpenAPIV3.ResponseObject
455
+ const content = respObj?.content || {}
456
+
457
+ // Collect response-level headers
458
+ const headerEntries: string[] = []
459
+ for (const [hName, hVal] of Object.entries(respObj?.headers || {})) {
460
+ if ('$ref' in (hVal as any)) continue
461
+ const h = hVal as OpenAPIV3.HeaderObject
462
+ const headerSchema = unref(
463
+ parseOapiSchema(h.schema || ({ type: 'string' } as any), { description: h.description }),
464
+ m => {
364
465
  let l = m.split('/')
365
466
  imports[l[l.length - 1]] = m
366
467
  return l[l.length - 1]
367
- })
468
+ }
368
469
  )
470
+ headerEntries.push(`${JSON.stringify(hName)}:${h.required ? headerSchema : `$T.optional(${headerSchema})`}`)
471
+ }
472
+ const description = typeof respObj?.description === 'string' && respObj.description ? respObj.description : undefined
473
+
474
+ if (Object.keys(content).length === 0) {
475
+ let nullSchema = description ? `$T.null({description:${JSON.stringify(description)}})` : `$T.null()`
476
+ if (headerEntries.length) nullSchema = `({...${nullSchema}, responseHeaders:{${headerEntries.join(',')}}})`
477
+ return [s, nullSchema]
478
+ }
479
+
480
+ // Group schemas by galbe body key, collect examples
481
+ const keyGroups: Record<string, string[]> = {}
482
+ const exampleParts: string[] = []
483
+ let singleExample: any = undefined
484
+ for (const [mediaType, tv] of Object.entries(content)) {
485
+ const galbeKey = mediaType
486
+ if ((tv as any).example !== undefined && singleExample === undefined) singleExample = (tv as any).example
487
+ if (tv.examples && Object.keys(tv.examples).length) {
488
+ for (const [k, ex] of Object.entries(tv.examples)) exampleParts.push(`${JSON.stringify(k)}:${JSON.stringify(ex)}`)
489
+ }
490
+ const schemaStr = unref(parseOapiSchema(tv.schema), m => {
491
+ let l = m.split('/')
492
+ imports[l[l.length - 1]] = m
493
+ return l[l.length - 1]
494
+ })
495
+ if (!keyGroups[galbeKey]) keyGroups[galbeKey] = []
496
+ keyGroups[galbeKey].push(schemaStr)
497
+ }
498
+
499
+ const uniqueKeys = Object.keys(keyGroups)
500
+
501
+ if (uniqueKeys.length > 1) {
502
+ // Multiple body keys → STResponseContent object
503
+ const parts: string[] = []
504
+ for (const [key, schemas] of Object.entries(keyGroups)) {
505
+ const unique = [...new Set(schemas)]
506
+ parts.push(`"${key}": ${unique.length === 1 ? unique[0] : `$T.union([${unique.join(',')}])`}`)
507
+ }
508
+ if (description) parts.push(`description: ${JSON.stringify(description)}`)
509
+ if (headerEntries.length) parts.push(`responseHeaders: {${headerEntries.join(',')}}`)
510
+ if (exampleParts.length) parts.push(`examples: {${exampleParts.join(',')}}`)
511
+ if (singleExample !== undefined) parts.push(`example: ${JSON.stringify(singleExample)}`)
512
+ return [s, `{${parts.join(',')}}`]
513
+ } else {
514
+ // Single body key → STResponseContent object (preserves exact media type key)
515
+ const [key] = uniqueKeys
516
+ const unique = [...new Set(keyGroups[key] || [])]
517
+ let schemaStr = unique.length === 0 ? `$T.null()` : unique.length === 1 ? unique[0] : `$T.union([${unique.join(',')}])`
518
+ // Embed per-media-type example/examples inside the schema spread so the
519
+ // content-map serializer can read them from the per-key body schema.
520
+ const perKeyExtras: string[] = []
521
+ if (exampleParts.length) perKeyExtras.push(`examples:{${exampleParts.join(',')}}`)
522
+ if (singleExample !== undefined) perKeyExtras.push(`example:${JSON.stringify(singleExample)}`)
523
+ if (perKeyExtras.length) schemaStr = `({...${schemaStr},${perKeyExtras.join(',')}})`
524
+ const parts: string[] = []
525
+ if (key) parts.push(`"${key}":${schemaStr}`)
526
+ if (description) parts.push(`description:${JSON.stringify(description)}`)
527
+ if (headerEntries.length) parts.push(`responseHeaders:{${headerEntries.join(',')}}`)
528
+ return [s, `{${parts.join(',')}}`]
369
529
  }
370
- return [s, [...new Set(entries)]]
371
530
  })
372
531
  )
373
532
 
374
533
  if (Object.keys(rs).length) {
375
534
  resp = ` response: {${Object.entries(rs)
376
- .filter(([_, v]) => v.length)
377
- .map(([s, v]) => `${s}: ${v.length === 1 ? v[0] : v.length > 1 ? `$T.union([${v.join(',')}])` : ''}`)
535
+ .filter(([_, v]) => v)
536
+ .map(([s, v]) => `${s}: ${v}`)
378
537
  .join(',')}}`
379
538
  } else resp = ''
380
539
 
@@ -397,18 +556,34 @@ const parseEndpoints = (def: OpenAPIV3.Document) => {
397
556
  let endpoints: Record<string, EndpointEntry> = {}
398
557
  for (let [fullPath, pathVal] of Object.entries(def.paths || {})) {
399
558
  if (!pathVal) continue
400
- let match = fullPath.match(/^\/?(v\d+[^\/]*\/)?(?:\/?(public|private))?\/?([^\/]+)\/?(.*)$/)
559
+ let match = fullPath.match(/^\/?(?:(v\d+)[^\/]*\/)?(?:\/?(public|private))?\/?([^\/]+)\/?(.*)$/)
401
560
  if (!match) continue
402
561
  let [_, version, visibility, scope, path] = [...match]
403
562
  path = `/${path}`
404
563
  let methods = ['get', 'put', 'patch', 'post', 'delete', 'options', 'head'] as const
564
+ let pathParams = pathVal.parameters || []
405
565
  for (let m of methods) {
406
566
  let endpointDef = pathVal?.[m]
407
567
  if (!endpointDef) continue
408
568
  let ref = `#/paths${version ? `/${version}` : ''}${visibility ? `/${visibility}` : ''}${
409
569
  scope ? `/${scope}` : ''
410
570
  }/${m}${path}`
411
- let { schema, endpoint } = parseEndpointDef(m, fullPath, endpointDef)
571
+ let opParams = endpointDef.parameters || []
572
+ let opKeys = new Set(
573
+ opParams
574
+ .map(p => {
575
+ let resolved = '$ref' in p ? resolveParamRef(p.$ref, def.components) : (p as OpenAPIV3.ParameterObject)
576
+ return resolved ? `${resolved.in}:${resolved.name}` : null
577
+ })
578
+ .filter((k): k is string => k !== null)
579
+ )
580
+ let inheritedParams = pathParams.filter(p => {
581
+ let resolved = '$ref' in p ? resolveParamRef(p.$ref, def.components) : (p as OpenAPIV3.ParameterObject)
582
+ if (!resolved) return false
583
+ return !opKeys.has(`${resolved.in}:${resolved.name}`)
584
+ })
585
+ let mergedDef = { ...endpointDef, parameters: [...inheritedParams, ...opParams] }
586
+ let { schema, endpoint } = parseEndpointDef(m, fullPath, mergedDef, def.components)
412
587
  endpoints[ref] = {
413
588
  version,
414
589
  visibility: visibility as 'public' | 'private',
@@ -423,56 +598,120 @@ const parseEndpoints = (def: OpenAPIV3.Document) => {
423
598
  return endpoints
424
599
  }
425
600
 
426
- const writeFiles = async (
427
- path: string,
428
- endpoints: Record<string, EndpointEntry>,
429
- schemaIndex: Record<string, SchemaEntry>,
430
- target: 'js' | 'ts'
431
- ) => {
432
- const typeMap = {
433
- schemas: 'commons',
434
- requestBodies: 'requests',
435
- responses: 'responses',
436
- }
437
- const parseSchemasToFile = (
438
- schemas: Record<string, SchemaEntry>,
439
- type: 'schemas' | 'requestBodies' | 'responses'
440
- ) => {
441
- if (Object.keys(schemas).length === 0) return ''
442
- let imports: Record<string, string[]> = {}
443
- let decl: string[] = []
444
- Object.entries(schemas).forEach(([k, s]) => {
445
- if (s.key === s.schema && s.dependsOn.size === 1) {
446
- let depMatch = [...s.dependsOn][0].match(/^#\/components\/([^\/]+)\/([^\/]+)/)
447
- if (!depMatch) return
448
- let [_, depOrig, depName] = [...depMatch]
449
- decl.push(`export { ${depName} } from './${typeMap[depOrig]}.schema'\n`)
450
- return
451
- }
452
- for (let dep of [k, ...s.dependsOn]) {
453
- let depMatch = dep.match(/^#\/components\/([^\/]+)\/([^\/]+)/)
454
- if (!depMatch) continue
455
- let [_, depOrig, depName] = [...depMatch]
456
- if (depOrig !== type) {
457
- if (!(depOrig in imports)) imports[depOrig] = []
458
- imports[depOrig].push(depName)
459
- }
601
+ const COMPONENT_TYPE_MAP = {
602
+ schemas: 'commons',
603
+ requestBodies: 'requests',
604
+ responses: 'responses',
605
+ } as const
606
+
607
+ const renderComponentSchemaFile = (
608
+ schemas: Record<string, SchemaEntry>,
609
+ type: 'schemas' | 'requestBodies' | 'responses'
610
+ ): string => {
611
+ if (Object.keys(schemas).length === 0) return ''
612
+ let imports: Record<string, string[]> = {}
613
+ let decl: string[] = []
614
+ Object.entries(schemas).forEach(([k, s]) => {
615
+ if (s.key === s.schema && s.dependsOn.size === 1) {
616
+ let depMatch = [...s.dependsOn][0].match(/^#\/components\/([^\/]+)\/([^\/]+)/)
617
+ if (!depMatch) return
618
+ let [_, depOrig, depName] = [...depMatch]
619
+ decl.push(`export { ${depName} } from './${COMPONENT_TYPE_MAP[depOrig as keyof typeof COMPONENT_TYPE_MAP]}.schema'\n`)
620
+ return
621
+ }
622
+ for (let dep of [k, ...s.dependsOn]) {
623
+ let depMatch = dep.match(/^#\/components\/([^\/]+)\/([^\/]+)/)
624
+ if (!depMatch) continue
625
+ let [_, depOrig, depName] = [...depMatch]
626
+ if (depOrig !== type) {
627
+ if (!(depOrig in imports)) imports[depOrig] = []
628
+ imports[depOrig].push(depName)
460
629
  }
630
+ }
631
+ // For requestBodies/responses that are just a single ref to another schema,
632
+ // preserve identity by tagging a _responseId / _requestBodyId rather than
633
+ // aliasing it (which would lose the original component name in the spec).
634
+ const isSingleAlias =
635
+ (type === 'responses' || type === 'requestBodies') &&
636
+ s.dependsOn.size === 1 &&
637
+ s.schema.trim() === [...s.dependsOn][0].split('/').pop()
638
+ const responseExtras: string[] = []
639
+ if (type === 'responses') {
640
+ if (s.responseDescription) responseExtras.push(`description: ${JSON.stringify(s.responseDescription)}`)
641
+ if (s.responseExample !== undefined) responseExtras.push(`example: ${JSON.stringify(s.responseExample)}`)
642
+ if (s.responseExamples) responseExtras.push(`examples: ${JSON.stringify(s.responseExamples)}`)
643
+ }
644
+ if (isSingleAlias) {
645
+ const tagKey = type === 'responses' ? '_responseId' : '_requestBodyId'
646
+ const extras = [`${tagKey}: "${s.key}"`, ...responseExtras]
647
+ decl.push(
648
+ `export const ${s.key} = { ...${s.schema}, ${extras.join(', ')} } as typeof ${s.schema}\nexport type ${s.key} = Static<typeof ${s.key}>\n`
649
+ )
650
+ } else if (type === 'responses' && responseExtras.length) {
651
+ decl.push(
652
+ `export const ${s.key} = {...${s.schema}, ${responseExtras.join(', ')}}\nexport type ${s.key} = Static<typeof ${s.key}>\n`
653
+ )
654
+ } else {
461
655
  decl.push(`export const ${s.key} = ${s.schema}\nexport type ${s.key} = Static<typeof ${s.key}>\n`)
462
- })
463
- if (decl.length === 0) return ''
464
- return `import type { Static } from 'galbe/schema'\nimport { $T } from 'galbe'\n${Object.entries(imports)
465
- .map(([k, v]) => `import { ${[...new Set(v)].join(', ')} } from './${typeMap[k]}.schema'\n`)
466
- .join('\n')}\n${decl.join('\n')}\n`
467
- }
656
+ }
657
+ })
658
+ if (decl.length === 0) return ''
659
+ return `import type { Static } from 'galbe/schema'\nimport { $T } from 'galbe'\n${Object.entries(imports)
660
+ .map(
661
+ ([k, v]) =>
662
+ `import { ${[...new Set(v)].join(', ')} } from './${COMPONENT_TYPE_MAP[k as keyof typeof COMPONENT_TYPE_MAP]}.schema'\n`
663
+ )
664
+ .join('\n')}\n${decl.join('\n')}\n`
665
+ }
666
+
667
+ export type RoutePlanEntry = {
668
+ /** HTTP method, lowercase (matches the property accessed on `g`). */
669
+ method: string
670
+ /** Path as emitted in the call expression: full prefix included, OpenAPI braces converted to `:param`. Stable identity for diff/rename. */
671
+ path: string
672
+ schemaName: string
673
+ /** JSDoc block string (e.g. '/**\n * summary\n *\/'). */
674
+ meta: string
675
+ /** Rendered call expression body, e.g. 'get("/path", FooSchema, ctx => { ... })'. */
676
+ call: string
677
+ }
678
+
679
+ export type ScopePlan = {
680
+ /** e.g. '/main', '/v1/public/admin'. */
681
+ scopeKey: string
682
+ /** Output path without extension, e.g. 'routes/main.route'. */
683
+ routeFile: string
684
+ /** Output path without extension, e.g. 'schemas/main.schema'. */
685
+ schemaFile: string
686
+ /** Imports to inject in the scope schema file: relative path -> imported names. */
687
+ schemaImports: Record<string, string[]>
688
+ /** 'export const X = {...}' declarations for the scope schema file. */
689
+ schemaDecls: string[]
690
+ /** Schema names to import in the scope route file from its sibling schema file. */
691
+ routeSchemaImports: string[]
692
+ routes: RoutePlanEntry[]
693
+ }
694
+
695
+ export type GenerationPlan = {
696
+ /** Component schema files (commons/requests/responses), ready to write. Path is relative to outDir without extension. */
697
+ componentFiles: { path: string; content: string }[]
698
+ scopes: ScopePlan[]
699
+ target: 'js' | 'ts'
700
+ }
468
701
 
702
+ export const buildPlan = (
703
+ endpoints: Record<string, EndpointEntry>,
704
+ schemaIndex: Record<string, SchemaEntry>,
705
+ target: 'js' | 'ts'
706
+ ): GenerationPlan => {
707
+ const componentFiles: { path: string; content: string }[] = []
469
708
  const sMaps = [
470
709
  { g: 'commons', o: 'schemas' },
471
710
  { g: 'requests', o: 'requestBodies' },
472
711
  { g: 'responses', o: 'responses' },
473
712
  ] as const
474
713
  for (let { g, o } of sMaps) {
475
- let s = parseSchemasToFile(
714
+ let content = renderComponentSchemaFile(
476
715
  orderDeps(
477
716
  Object.fromEntries(
478
717
  Object.entries(schemaIndex).filter(([k, _]) => {
@@ -482,69 +721,117 @@ const writeFiles = async (
482
721
  ),
483
722
  o
484
723
  )
485
- if (s) await writeCodeFile(resolve(path, 'schemas', `${g}.schema`), s, target)
724
+ if (content) componentFiles.push({ path: `schemas/${g}.schema`, content })
486
725
  }
487
726
 
488
- let scopedDefs = Object.entries(endpoints).reduce((p, [_, v]) => {
727
+ let scopedDefs = Object.entries(endpoints).reduce<Record<string, EndpointEntry[]>>((p, [_, v]) => {
489
728
  let scopeKey = `${v.version ? `/${v.version}` : ''}${v.visibility ? `/${v.visibility}` : ''}${
490
729
  v.scope ? `/${v.scope}` : '/main'
491
730
  }`
492
731
  if (!(scopeKey in p)) p[scopeKey] = []
493
732
  p[scopeKey].push(v)
494
733
  return p
495
- }, {}) as Record<string, EndpointEntry[]>
734
+ }, {})
496
735
 
736
+ const scopes: ScopePlan[] = []
497
737
  for (let [scopeKey, def] of Object.entries(scopedDefs)) {
498
- let routePath = `routes${scopeKey}.route`
499
- let schemaPath = `schemas${scopeKey}.schema`
738
+ let routeFile = `routes${scopeKey}.route`
739
+ let schemaFile = `schemas${scopeKey}.schema`
500
740
 
501
741
  let sImports: Record<string, Set<string>> = {}
502
742
  let sDecl: string[] = []
503
-
504
743
  let rImports: Set<string> = new Set()
505
- let rDecl: string[] = []
744
+ let routes: RoutePlanEntry[] = []
506
745
 
507
746
  for (let d of def) {
508
- // schema
509
747
  Object.entries(d.schema?.imports || {}).forEach(([iK, dep]) => {
510
- let k = refToPath(dep, dirname(schemaPath))
748
+ let k = refToPath(dep, dirname(schemaFile))
511
749
  if (!k) return
512
750
  if (!(k in sImports)) sImports[k] = new Set()
513
751
  sImports[k].add(iK)
514
752
  })
515
753
  sDecl.push(`export const ${d.schema?.name} = ${d.schema?.def}`)
516
754
 
517
- // route
518
755
  let ep = d.endpoint
519
756
  if (!ep) continue
520
757
  if (d.schema?.name) rImports.add(d.schema?.name)
521
- rDecl.push(` ${ep.meta}\ng.${ep.def}`)
758
+ // Source of truth for the path is the rendered call expression itself —
759
+ // reconstructing from version/visibility/scope/path drifts on edge cases
760
+ // (e.g. an empty trailing segment yields `/users/` instead of `/users`).
761
+ const callPathMatch = (ep.def ?? '').match(/^\w+\("([^"]*)"/)
762
+ const emittedPath = callPathMatch?.[1] ?? ''
763
+ routes.push({
764
+ method: d.method ?? '',
765
+ path: emittedPath,
766
+ schemaName: d.schema?.name ?? '',
767
+ meta: ep.meta ?? '',
768
+ call: ep.def ?? '',
769
+ })
522
770
  }
523
771
 
524
- let schemaFile =
525
- `import { $T } from 'galbe'\n\n` +
526
- `${Object.entries(sImports)
527
- .map(([k, v]) => `import { ${[...v].join(', ')} } from '${k}'`)
528
- .join('\n')}\n\n` +
529
- `${sDecl.map(d => d).join('\n\n')}\n`
530
-
531
- let routeFile =
532
- `import { NotImplementedError, type Galbe } from 'galbe'\n` +
533
- `import { ${[...rImports].join(', ')} } from '../schemas${scopeKey}.schema'\n\n` +
534
- `export default (g: Galbe) => {\n` +
535
- rDecl.map(d => d.replaceAll('\n', '\n ')).join('\n\n') +
536
- `\n}\n`
537
-
538
- if (sDecl?.length) await writeCodeFile(resolve(path, schemaPath), schemaFile, target)
539
- if (rDecl?.length) await writeCodeFile(resolve(path, routePath), routeFile, target)
772
+ scopes.push({
773
+ scopeKey,
774
+ routeFile,
775
+ schemaFile,
776
+ schemaImports: Object.fromEntries(Object.entries(sImports).map(([k, v]) => [k, [...v]])),
777
+ schemaDecls: sDecl,
778
+ routeSchemaImports: [...rImports],
779
+ routes,
780
+ })
540
781
  }
782
+
783
+ return { componentFiles, scopes, target }
541
784
  }
542
785
 
543
- export const generateFromOapi = async (
786
+ export type ApplyPlanOptions = {
787
+ /** Override the route file content for given scope keys. When set, the value is written verbatim
788
+ * instead of fresh-rendering from the plan — used by the merger to preserve user code. */
789
+ routeContents?: Map<string, string>
790
+ }
791
+
792
+ export const applyPlan = async (plan: GenerationPlan, outDir: string, opts: ApplyPlanOptions = {}): Promise<void> => {
793
+ const { target } = plan
794
+
795
+ for (const f of plan.componentFiles) {
796
+ await writeCodeFile(resolve(outDir, f.path), f.content, target)
797
+ }
798
+
799
+ for (const scope of plan.scopes) {
800
+ if (scope.schemaDecls.length) {
801
+ const schemaContent =
802
+ `import { $T } from 'galbe'\n\n` +
803
+ `${Object.entries(scope.schemaImports)
804
+ .map(([k, v]) => `import { ${v.join(', ')} } from '${k}'`)
805
+ .join('\n')}\n\n` +
806
+ `${scope.schemaDecls.join('\n\n')}\n`
807
+ await writeCodeFile(resolve(outDir, scope.schemaFile), schemaContent, target)
808
+ }
809
+
810
+ if (scope.routes.length) {
811
+ const override = opts.routeContents?.get(scope.scopeKey)
812
+ let routeContent: string
813
+ if (override !== undefined) {
814
+ routeContent = override
815
+ } else {
816
+ const deepness = scope.scopeKey.split('/').length - 1
817
+ const importPath = `${Array(deepness).fill('../').join('')}schemas${scope.scopeKey}.schema`
818
+ const rDecl = scope.routes.map(r => ` ${r.meta}\ng.${r.call}`)
819
+ routeContent =
820
+ `import { NotImplementedError, type Galbe } from 'galbe'\n` +
821
+ `import { ${scope.routeSchemaImports.join(', ')} } from '${importPath}'\n\n` +
822
+ `export default (g: Galbe) => {\n` +
823
+ rDecl.map(d => d.replaceAll('\n', '\n ')).join('\n\n') +
824
+ `\n}\n`
825
+ }
826
+ await writeCodeFile(resolve(outDir, scope.routeFile), routeContent, target)
827
+ }
828
+ }
829
+ }
830
+
831
+ export const planFromOapi = async (
544
832
  input: string,
545
- out: string,
546
833
  { version, ext, target }: { version: string; ext: 'json' | 'yaml'; target: 'js' | 'ts' }
547
- ) => {
834
+ ): Promise<GenerationPlan> => {
548
835
  let def: OpenAPIV3.Document =
549
836
  ext === 'json' ? await Bun.file(input).json() : Bun.YAML.parse(await Bun.file(input).text())
550
837
 
@@ -554,5 +841,13 @@ export const generateFromOapi = async (
554
841
  let schemaIndex = buildSchemaIndex(def)
555
842
  let endpointDefs = parseEndpoints(def)
556
843
 
557
- await writeFiles(out, endpointDefs, schemaIndex, target)
844
+ return buildPlan(endpointDefs, schemaIndex, target)
845
+ }
846
+
847
+ export const generateFromOapi = async (
848
+ input: string,
849
+ out: string,
850
+ opts: { version: string; ext: 'json' | 'yaml'; target: 'js' | 'ts' }
851
+ ) => {
852
+ await applyPlan(await planFromOapi(input, opts), out)
558
853
  }