galbe 0.15.5 → 0.16.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,5 +1,5 @@
1
1
  import { semver } from 'bun'
2
- import { transformSync } from '@swc/core'
2
+ import { transformSync } from '@swc/wasm'
3
3
  import { resolve, relative, dirname } from 'path'
4
4
  import { OpenAPIV3 } from 'openapi-types'
5
5
 
@@ -16,17 +16,46 @@ type SchemaEntry = {
16
16
  responseExample?: any
17
17
  /** Response-only: content-level multi-key examples. */
18
18
  responseExamples?: Record<string, any>
19
+ /** Response-only: the response's `links`, with component refs already inlined. */
20
+ responseLinks?: Record<string, any>
21
+ /** Response-only: the response's headers as `[name, schema source]` pairs. */
22
+ responseHeaders?: [string, string][]
23
+ /**
24
+ * Response-only: the response's bodies as `[mediaType, schema]` pairs. Kept
25
+ * even for a single media type so the component can be emitted in the
26
+ * content-map form — decorating a schema by spread would leak the response's
27
+ * `description`/`example` into the shared component schema it refers to.
28
+ */
29
+ responseContent?: [string, string][]
30
+ /** RequestBody-only: the bodies as `[mediaType, schema]` pairs, same rationale as `responseContent`. */
31
+ requestContent?: [string, string][]
32
+ /** RequestBody-only: the component-level description and requiredness. */
33
+ requestDescription?: string
34
+ requestRequired?: boolean
19
35
  }
20
36
  type EndpointEntry = {
21
- version?: string
22
- visibility?: 'public' | 'private'
23
- scope?: string
37
+ /** leading literal path segments — the last one names the route file, the ones before it the directory */
38
+ scope: string[]
24
39
  method?: 'get' | 'put' | 'patch' | 'post' | 'delete' | 'options' | 'head'
40
+ /** path as emitted in the file, relative to the file's directory prefix */
25
41
  path?: string
26
42
  schema?: { imports: Record<string, string>; name: string; def: string }
27
43
  endpoint?: { meta?: string; def?: string }
28
44
  }
29
45
 
46
+ /**
47
+ * A construct the spec declares and the generated Galbe sources cannot carry.
48
+ * Collected while planning and reported by `generate code`: a silently widened
49
+ * validator is a security-adjacent surprise, a warning makes it a choice.
50
+ */
51
+ export type GenerationWarning = { at: string; message: string }
52
+ let warnings: GenerationWarning[] = []
53
+ // where the walk currently is, so a warning raised deep in a schema can say so
54
+ let warnAt = ''
55
+ const warn = (message: string, at = warnAt) => {
56
+ if (!warnings.some(w => w.at === at && w.message === message)) warnings.push({ at, message })
57
+ }
58
+
30
59
  // Util
31
60
  const unref = (code: string, cb: (m: string) => string) => code.replaceAll(/%ref:([^%]*)%/g, (_, m) => cb(m))
32
61
  const refToPath = (ref: string, basePath?: string) => {
@@ -75,14 +104,21 @@ const orderDeps = (deps: Record<string, SchemaEntry>) => {
75
104
  }
76
105
  return Object.fromEntries([...l].map(k => [k, deps[k]]))
77
106
  }
107
+ /**
108
+ * Marks a value that is already Galbe source — a nested `$T.…` builder — so it
109
+ * comes back out of `serialize` as code instead of a quoted string.
110
+ */
111
+ const raw = (code: string) => `__RAW__${code}__ENDRAW__`
78
112
  const serialize = (obj: any) => {
79
113
  return JSON.stringify(obj, (k, value) => {
80
114
  if (k === 'pattern' && value) return `__PATTERN__${value}__ENDPATTERN__`
81
115
  return value
82
- }).replace(/"__PATTERN__([\s\S]*?)__ENDPATTERN__"/g, (_, body) => {
83
- const decoded = JSON.parse(`"${body}"`) as string
84
- return `/${decoded.replace(/\//g, '\\/')}/`
85
116
  })
117
+ .replace(/"__PATTERN__([\s\S]*?)__ENDPATTERN__"/g, (_, body) => {
118
+ const decoded = JSON.parse(`"${body}"`) as string
119
+ return `/${decoded.replace(/\//g, '\\/')}/`
120
+ })
121
+ .replace(/"__RAW__([\s\S]*?)__ENDRAW__"/g, (_, body) => JSON.parse(`"${body}"`) as string)
86
122
  }
87
123
 
88
124
  const writeCodeFile = async (path: string, content: string, target: 'js' | 'ts') => {
@@ -93,7 +129,8 @@ const writeCodeFile = async (path: string, content: string, target: 'js' | 'ts')
93
129
  syntax: 'typescript',
94
130
  },
95
131
  preserveAllComments: true,
96
- target: 'esnext',
132
+ // @swc/wasm's JscTarget typing lags @swc/core's; 'esnext' is supported at runtime
133
+ target: 'esnext' as any,
97
134
  },
98
135
  }).code
99
136
  }
@@ -102,8 +139,8 @@ const writeCodeFile = async (path: string, content: string, target: 'js' | 'ts')
102
139
 
103
140
  const parseOapiSchema = (
104
141
  os?: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject,
105
- details: { id?: string; title?: string; description?: string } = {},
106
- extra?: { media?: string }
142
+ details: { id?: string; title?: string; description?: string; deprecated?: boolean } = {},
143
+ extra?: { media?: string; skipNullable?: boolean; encoding?: Record<string, any>; split?: string }
107
144
  ): string => {
108
145
  if (!os) {
109
146
  return `$T.any(${details && Object.keys(details).length ? JSON.stringify(details) : ''})`
@@ -112,6 +149,11 @@ const parseOapiSchema = (
112
149
  const ref: string | undefined = os?.$ref
113
150
  if (ref) return `%ref:${ref}%`
114
151
  os = os as OpenAPIV3.SchemaObject
152
+ // `nullable` is valid on any schema, not only on an object's properties, so
153
+ // it is applied once here — array items, component schemas and composition
154
+ // members included. The object branch opts out through `skipNullable`: it
155
+ // folds nullability together with optionality into `$T.nullish`.
156
+ const nullable = !extra?.skipNullable && os.nullable === true
115
157
  let options: typeof details & {
116
158
  min?: number
117
159
  max?: number
@@ -119,18 +161,27 @@ const parseOapiSchema = (
119
161
  exclusiveMax?: number
120
162
  minLength?: number
121
163
  maxLength?: number
164
+ multipleOf?: number
122
165
  pattern?: string
123
166
  format?: string
124
167
  minItems?: number
125
168
  maxItems?: number
126
169
  unique?: boolean
170
+ split?: string
127
171
  default?: any
128
172
  examples?: any
173
+ readOnly?: boolean
174
+ writeOnly?: boolean
175
+ encoding?: Record<string, any>
176
+ /** already-rendered source, injected through `raw()` */
177
+ additionalProperties?: string
129
178
  } = {
130
179
  ...details,
131
180
  title: os.title,
132
181
  description: details.description || os.description,
133
182
  default: os.default,
183
+ ...(os.readOnly ? { readOnly: true } : {}),
184
+ ...(os.writeOnly ? { writeOnly: true } : {}),
134
185
  ...(os.example !== undefined ? { examples: os.example } : {}),
135
186
  }
136
187
 
@@ -165,7 +216,21 @@ const parseOapiSchema = (
165
216
  )})`
166
217
  }
167
218
  } else if (!os?.type) {
168
- return `$T.any(${hasOptions ? optArg : ''})`
219
+ if (os.not) warn('`not` has no equivalent in Galbe — the constraint is dropped and the value validates as `any`')
220
+ resp = `$T.any(${hasOptions ? optArg : ''})`
221
+ } else if (
222
+ os.enum?.length &&
223
+ os.format !== 'binary' &&
224
+ ['string', 'integer', 'number', 'boolean'].includes(os.type as string)
225
+ ) {
226
+ // An `enum` closes the value set for any primitive type, not just strings:
227
+ // one value is a literal, several are a union of literals. Both carry the
228
+ // options — a literal that drops them loses its description.
229
+ const literals = os.enum.map(v => `$T.literal(${JSON.stringify(v)})`)
230
+ resp =
231
+ literals.length === 1
232
+ ? `$T.literal(${JSON.stringify(os.enum[0])}${optArg ? `, ${optArg}` : ''})`
233
+ : `$T.union([${literals.join(', ')}]${optArg ? `, ${optArg}` : ''})`
169
234
  } else if (os.type === 'boolean') resp = `$T.boolean(${hasOptions ? serialize(options) : ''})`
170
235
  else if (os.type === 'number') {
171
236
  let { max, min, exclusiveMax, exclusiveMin } = {
@@ -174,7 +239,7 @@ const parseOapiSchema = (
174
239
  exclusiveMax: os.maximum !== undefined && os.exclusiveMaximum ? os.maximum : undefined,
175
240
  exclusiveMin: os.minimum !== undefined && os.exclusiveMinimum ? os.minimum : undefined,
176
241
  }
177
- options = { ...options, min, max, exclusiveMax, exclusiveMin }
242
+ options = { ...options, min, max, exclusiveMax, exclusiveMin, multipleOf: os.multipleOf, format: os.format }
178
243
  hasOptions = Object.values(options).some(v => v !== undefined)
179
244
  resp = `$T.number(${hasOptions ? serialize(options) : ''})`
180
245
  } else if (os.type === 'integer') {
@@ -182,17 +247,12 @@ const parseOapiSchema = (
182
247
  let min = os.minimum !== undefined && !os.exclusiveMinimum ? os.minimum : undefined
183
248
  let exclusiveMax = os.maximum !== undefined && os.exclusiveMaximum ? os.maximum : undefined
184
249
  let exclusiveMin = os.minimum !== undefined && os.exclusiveMinimum ? os.minimum : undefined
185
- options = { ...options, min, max, exclusiveMax, exclusiveMin }
250
+ options = { ...options, min, max, exclusiveMax, exclusiveMin, multipleOf: os.multipleOf, format: os.format }
186
251
  hasOptions = Object.values(options).some(v => v !== undefined)
187
252
  resp = `$T.integer(${hasOptions ? serialize(options) : ''})`
188
253
  } else if (os.type === 'string') {
189
254
  if (os.format === 'binary') resp = `$T.byteArray(${hasOptions ? serialize(options) : ''})`
190
- else if (os.enum?.length === 1) {
191
- resp = `$T.literal("${os.enum[0]}")`
192
- } else if (os.enum?.length) {
193
- const literals = os.enum.map(v => `$T.literal("${v}")`).join(', ')
194
- resp = `$T.union([${literals}]${optArg ? `, ${optArg}` : ''})`
195
- } else {
255
+ else {
196
256
  let minLength = os.minLength
197
257
  let maxLength = os.maxLength
198
258
  let pattern = os.pattern
@@ -207,7 +267,7 @@ const parseOapiSchema = (
207
267
  let minLength = os.minItems
208
268
  let maxLength = os.maxItems
209
269
  let unique = os.uniqueItems
210
- options = { ...options, minLength, maxLength, unique }
270
+ options = { ...options, minLength, maxLength, unique, split: extra?.split }
211
271
  hasOptions = Object.values(options).some(v => v !== undefined)
212
272
  optArg = hasOptions ? serialize(options) : ''
213
273
  resp = `$T.array(${parseOapiSchema(os?.items)}${optArg ? `, ${optArg}` : ''})`
@@ -223,19 +283,38 @@ const parseOapiSchema = (
223
283
  else if (v.nullable) return `$T.nullable(${s})`
224
284
  return s
225
285
  }
226
- return `"${k}":${w(parseOapiSchema(v))}`
286
+ // `w` owns this property's nullability, so the recursive call must not
287
+ // also wrap it — otherwise a nullable property comes back doubly wrapped.
288
+ return `"${k}":${w(parseOapiSchema(v, {}, { skipNullable: true }))}`
227
289
  })
228
290
  .join(',')
291
+ // `additionalProperties: true` is the OpenAPI default — only the two
292
+ // constraining forms carry information worth emitting.
293
+ const apDef = os.additionalProperties
294
+ const ap =
295
+ apDef === false ? 'false' : apDef && apDef !== true ? parseOapiSchema(apDef as OpenAPIV3.SchemaObject) : undefined
296
+
229
297
  if (extra?.media === 'multipart/form-data') {
298
+ // `encoding` sits on the media type in the spec; Galbe carries it on the
299
+ // multipartForm schema, which is the only place it can live
300
+ if (extra.encoding && Object.keys(extra.encoding).length) {
301
+ options = { ...options, encoding: extra.encoding }
302
+ optArg = serialize(options)
303
+ }
230
304
  resp = `$T.multipartForm({${props}}${optArg ? `, ${optArg}` : ''})`
231
- } else if (extra?.media === 'application/x-www-form-urlencoded') {
232
- resp = `$T.object({${props}}${optArg ? `, ${optArg}` : ''})`
305
+ } else if (ap !== undefined && ap !== 'false' && !props) {
306
+ // a free-form map: no declared properties, one schema for every value
307
+ resp = `$T.record(${ap}${optArg ? `, ${optArg}` : ''})`
233
308
  } else {
309
+ if (ap !== undefined) {
310
+ options = { ...options, additionalProperties: raw(ap) }
311
+ optArg = serialize(options)
312
+ }
234
313
  resp = `$T.object({${props}}${optArg ? `, ${optArg}` : ''})`
235
314
  }
236
315
  } else throw new Error(`Unknown schema type ${JSON.stringify(os)}`)
237
316
 
238
- return resp
317
+ return nullable ? `$T.nullable(${resp})` : resp
239
318
  }
240
319
 
241
320
  const buildSchemaIndex = (def: OpenAPIV3.Document) => {
@@ -246,25 +325,39 @@ const buildSchemaIndex = (def: OpenAPIV3.Document) => {
246
325
  let dependsOn = new Set<string>()
247
326
  let responseExample: any = undefined
248
327
  let responseExamples: Record<string, any> | undefined = undefined
328
+ let responseLinks: Record<string, any> | undefined = undefined
329
+ let responseHeaders: [string, string][] | undefined = undefined
330
+ let responseContent: [string, string][] | undefined = undefined
331
+ let requestContent: [string, string][] | undefined = undefined
249
332
  if (kind === 'schemas') schema = parseOapiSchema(s, { id: k })
250
333
  else if (kind === 'requestBodies') {
251
- let schemas = [] as string[]
252
- if (!!s.content) {
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 })))]
255
- } else {
256
- schemas = [parseOapiSchema(undefined, { id: k, ...s })]
257
- }
258
- schema = schemas.length <= 0 ? '' : schemas.length === 1 ? schemas[0] : `$T.union([${schemas.join(',')}])`
334
+ const contentMap = (s as OpenAPIV3.RequestBodyObject)?.content
335
+ requestContent = Object.entries(contentMap || {}).map(([media, v]) => [
336
+ media,
337
+ parseOapiSchema(v.schema, {}, { media, encoding: (v as any).encoding }),
338
+ ])
339
+ schema = `{${requestContent.map(([m, v]) => `"${m}": ${v}`).join(',')}}`
259
340
  } else if (kind === 'responses') {
341
+ responseLinks = resolveLinks((s as OpenAPIV3.ResponseObject)?.links as any, def.components)
342
+ responseHeaders = responseHeaderEntries((s as OpenAPIV3.ResponseObject)?.headers, def.components)
260
343
  if (!s.content) {
261
- schema = parseOapiSchema(undefined, { id: k, ...s })
344
+ // a bodiless component response: no media types, description only
345
+ responseContent = []
262
346
  } else {
263
- const contentMap = s.content as Record<string, { schema?: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject; example?: any; examples?: any }>
347
+ const contentMap = s.content as Record<
348
+ string,
349
+ { schema?: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject; example?: any; examples?: any }
350
+ >
264
351
  const entries = Object.entries(contentMap)
265
352
  for (const [, v] of entries) {
266
353
  if (v.example !== undefined && responseExample === undefined) responseExample = v.example
267
- if (v.examples && Object.keys(v.examples).length) responseExamples = { ...(responseExamples || {}), ...v.examples }
354
+ if (v.examples && Object.keys(v.examples).length)
355
+ responseExamples = {
356
+ ...(responseExamples || {}),
357
+ ...Object.fromEntries(
358
+ Object.entries(v.examples).map(([k, ex]) => [k, resolveExample(ex, def.components)])
359
+ ),
360
+ }
268
361
  }
269
362
  const keyGroups: Record<string, string[]> = {}
270
363
  for (const [media, v] of entries) {
@@ -273,36 +366,44 @@ const buildSchemaIndex = (def: OpenAPIV3.Document) => {
273
366
  if (!keyGroups[galbeKey]) keyGroups[galbeKey] = []
274
367
  keyGroups[galbeKey].push(schemaStr)
275
368
  }
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
- }
369
+ responseContent = Object.entries(keyGroups).map(([k, schemas]) => {
370
+ const unique = [...new Set(schemas)]
371
+ return [k, unique.length === 1 ? unique[0]! : `$T.union([${unique.join(',')}])`]
372
+ })
373
+ schema = `{${responseContent.map(([k, v]) => `"${k}": ${v}`).join(',')}}`
288
374
  }
289
375
  }
290
- schema = unref(schema, m => {
291
- let l = m.split('/')
292
- dependsOn.add(m)
293
- return l[l.length - 1]
294
- })
376
+ const deref = (code: string) =>
377
+ unref(code, m => {
378
+ let l = m.split('/')
379
+ dependsOn.add(m)
380
+ return l[l.length - 1]!
381
+ })
382
+ schema = deref(schema)
383
+ responseContent = responseContent?.map(([media, sc]) => [media, deref(sc)])
384
+ requestContent = requestContent?.map(([media, sc]) => [media, deref(sc)])
385
+ responseHeaders = responseHeaders?.map(([name, sc]) => [name, deref(sc)])
295
386
  index[`#/components/${kind}/${k}`] = {
296
387
  key: k,
297
388
  prefix: '',
298
389
  schema,
299
390
  dependsOn,
300
391
  usedBy: new Set(),
392
+ ...(kind === 'responses' ? { responseContent: responseContent ?? [] } : {}),
393
+ ...(kind === 'requestBodies'
394
+ ? {
395
+ requestContent: requestContent ?? [],
396
+ ...(typeof s?.description === 'string' && s.description ? { requestDescription: s.description } : {}),
397
+ ...(s?.required !== undefined ? { requestRequired: !!s.required } : {}),
398
+ }
399
+ : {}),
301
400
  ...(kind === 'responses' && typeof s?.description === 'string' && s.description
302
401
  ? { responseDescription: s.description }
303
402
  : {}),
304
403
  ...(kind === 'responses' && responseExample !== undefined ? { responseExample } : {}),
305
404
  ...(kind === 'responses' && responseExamples ? { responseExamples } : {}),
405
+ ...(kind === 'responses' && responseLinks && Object.keys(responseLinks).length ? { responseLinks } : {}),
406
+ ...(kind === 'responses' && responseHeaders?.length ? { responseHeaders } : {}),
306
407
  }
307
408
  }
308
409
  for (let [k, v] of Object.entries(def.components?.schemas || {})) initSchema(k, v, 'schemas')
@@ -316,6 +417,100 @@ const buildSchemaIndex = (def: OpenAPIV3.Document) => {
316
417
  return index
317
418
  }
318
419
 
420
+ /**
421
+ * Inline a `#/components/examples/*` reference. Galbe carries examples as plain
422
+ * values on the schema, with nowhere to keep a components entry, so a `$ref`
423
+ * left as-is would dangle in the regenerated spec.
424
+ */
425
+ const resolveExample = (ex: any, components: OpenAPIV3.ComponentsObject | undefined, seen = new Set<string>()): any => {
426
+ const ref: unknown = ex?.$ref
427
+ if (typeof ref !== 'string' || seen.has(ref)) return ex
428
+ let match = ref.match(/^#\/components\/examples\/(.+)$/)
429
+ if (!match) return ex
430
+ let target = components?.examples?.[match[1]!]
431
+ if (!target) return ex
432
+ seen.add(ref)
433
+ return resolveExample(target, components, seen)
434
+ }
435
+
436
+ /**
437
+ * Resolve a `#/components/headers/*` reference. Galbe carries response headers
438
+ * inline on the response, with nowhere to keep a components entry, so the
439
+ * header is inlined — the component's identity is lost but its shape is not,
440
+ * which beats the silent skip this replaces.
441
+ */
442
+ const resolveHeaderRef = (
443
+ ref: string,
444
+ components: OpenAPIV3.ComponentsObject | undefined,
445
+ seen = new Set<string>()
446
+ ): OpenAPIV3.HeaderObject | undefined => {
447
+ if (seen.has(ref)) return undefined
448
+ let match = ref.match(/^#\/components\/headers\/(.+)$/)
449
+ if (!match) return undefined
450
+ let target = components?.headers?.[match[1]!]
451
+ if (!target) return undefined
452
+ seen.add(ref)
453
+ if ('$ref' in target) return resolveHeaderRef(target.$ref, components, seen)
454
+ return target
455
+ }
456
+
457
+ /**
458
+ * Resolve a `#/components/links/*` reference. Galbe carries links inline on the
459
+ * response, with nowhere to keep a components entry, so the link is inlined —
460
+ * the component's name is lost, its content is not.
461
+ */
462
+ const resolveLinkRef = (
463
+ ref: string,
464
+ components: OpenAPIV3.ComponentsObject | undefined,
465
+ seen = new Set<string>()
466
+ ): any => {
467
+ if (seen.has(ref)) return undefined
468
+ let match = ref.match(/^#\/components\/links\/(.+)$/)
469
+ if (!match) return undefined
470
+ let target = (components?.links as Record<string, any> | undefined)?.[match[1]!]
471
+ if (!target) return undefined
472
+ seen.add(ref)
473
+ if ('$ref' in target) return resolveLinkRef(target.$ref, components, seen)
474
+ return target
475
+ }
476
+
477
+ /** Every link on a response, with `$ref`s inlined. Empty when the response declares none. */
478
+ const resolveLinks = (
479
+ links: Record<string, any> | undefined,
480
+ components: OpenAPIV3.ComponentsObject | undefined
481
+ ): Record<string, any> => {
482
+ const out: Record<string, any> = {}
483
+ for (const [name, link] of Object.entries(links ?? {})) {
484
+ const resolved = link && '$ref' in link ? resolveLinkRef(link.$ref, components) : link
485
+ if (resolved) out[name] = resolved
486
+ }
487
+ return out
488
+ }
489
+
490
+ /**
491
+ * A response's headers as `[name, schema source]` pairs, with
492
+ * `#/components/headers/*` references resolved and `%ref:%` markers left in
493
+ * place. Deliberately strategy-free: a route file resolves those markers
494
+ * through its import map and a component file through its dependency set, and
495
+ * baking either one in is what kept component responses from carrying headers.
496
+ */
497
+ const responseHeaderEntries = (
498
+ headers: OpenAPIV3.ResponseObject['headers'],
499
+ components: OpenAPIV3.ComponentsObject | undefined
500
+ ): [string, string][] => {
501
+ const out: [string, string][] = []
502
+ for (const [name, value] of Object.entries(headers ?? {})) {
503
+ let h: OpenAPIV3.HeaderObject | undefined
504
+ if ('$ref' in (value as any)) {
505
+ h = resolveHeaderRef((value as any).$ref, components)
506
+ if (!h) continue
507
+ } else h = value as OpenAPIV3.HeaderObject
508
+ const code = parseOapiSchema(h.schema || ({ type: 'string' } as any), { description: h.description })
509
+ out.push([name, h.required ? code : `$T.optional(${code})`])
510
+ }
511
+ return out
512
+ }
513
+
319
514
  const resolveParamRef = (
320
515
  ref: string,
321
516
  components: OpenAPIV3.ComponentsObject | undefined
@@ -331,25 +526,49 @@ const resolveParamRef = (
331
526
  const parseEndpointDef = (
332
527
  method: string,
333
528
  path: string,
529
+ emitPath: string,
334
530
  def?: OpenAPIV3.OperationObject,
335
531
  components?: OpenAPIV3.ComponentsObject
336
532
  ) => {
337
533
  if (!def) return {}
338
534
  let imports: Record<string, string> = {}
339
- let p = path.replaceAll(/\{([^\}]*)\}/g, ':$1')
535
+ // every `%ref:%` in this operation resolves to a name the route file imports
536
+ const deref = (code: string): string =>
537
+ unref(code, m => {
538
+ const l = m.split('/')
539
+ imports[l[l.length - 1]!] = m
540
+ return l[l.length - 1]!
541
+ })
542
+ // the emitted path is relative to the file's directory prefix; the schema
543
+ // name derives from the full path so it stays unique across scopes
544
+ let p = emitPath.replaceAll(/\{([^\}]*)\}/g, ':$1')
340
545
  // let description = def.summary || def.description
341
546
  let schemaName = def.operationId
342
547
  ? def.operationId.replace(/^\w/, c => c.toUpperCase())
343
548
  : `${method}${path
344
549
  .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())
550
+ .replaceAll(/[^$\w\d_]+([$\w\d_])/g, (_, $1) => $1.toUpperCase())}`.replace(/^\w/, c => c.toUpperCase())
347
551
 
552
+ warnAt = `${method.toUpperCase()} ${path}`
348
553
  let meta = '/**\n'
349
- if (def.summary) meta += ` * ${def.summary}\n *\n`
350
- if (def.description) meta += ` * ${def.description.replace(/\n/g, '\n * ')}\n`
554
+ // The JSDoc head expresses "summary, then description" and nothing else, so a
555
+ // description with no summary needs the explicit tags: a bare `@summary`
556
+ // declares the empty one the head convention cannot write down.
557
+ const headExpressible = !def.description || !!def.summary
558
+ if (headExpressible) {
559
+ if (def.summary) meta += ` * ${def.summary}\n *\n`
560
+ if (def.description) meta += ` * ${def.description.replace(/\n/g, '\n * ')}\n`
561
+ } else {
562
+ if (typeof def.summary === 'string') meta += ` * @summary\n`
563
+ for (const line of def.description!.split('\n')) meta += ` * @description ${line}\n`
564
+ }
351
565
  if (def.operationId) meta += ` * @operationId ${def.operationId}\n`
352
- if (def.externalDocs?.url) meta += ` * @externalDocs ${def.externalDocs.url}\n`
566
+ // `@externalDocs <url> [description]` the serializer splits it back on the
567
+ // first whitespace, so the description survives the roundtrip.
568
+ if (def.externalDocs?.url)
569
+ meta += ` * @externalDocs ${def.externalDocs.url}${
570
+ def.externalDocs.description ? ` ${def.externalDocs.description}` : ''
571
+ }\n`
353
572
  if (def.tags) meta += ` * @tags ${def.tags.join(' ')}\n`
354
573
  if (Array.isArray(def.security)) {
355
574
  if (def.security.length === 0) {
@@ -372,7 +591,14 @@ const parseEndpointDef = (
372
591
  meta += ' */'
373
592
  let endpoint = `${method}("${p}", ${schemaName}, ctx => {\n throw new NotImplementedError()\n})`
374
593
 
375
- let sp: Record<string, Record<string, string>> = { path: {}, query: {}, header: {}, body: {}, formData: {} } // TODO handle body and formData cases
594
+ let sp: Record<string, Record<string, string>> = {
595
+ path: {},
596
+ query: {},
597
+ header: {},
598
+ cookie: {},
599
+ body: {},
600
+ formData: {},
601
+ } // TODO handle body and formData cases
376
602
 
377
603
  for (let _p of def?.parameters || []) {
378
604
  let p: OpenAPIV3.ParameterObject | undefined
@@ -386,19 +612,40 @@ const parseEndpointDef = (
386
612
  s = so ?? s
387
613
  return p.in !== 'path' && !p.required ? `$T.optional(${s})` : s
388
614
  }
615
+ // A parameter is typed either by `schema` or by `content` (a single media
616
+ // type). Galbe cannot record *which* media type it was serialized as, so
617
+ // the roundtrip stays lossy there — but keeping the shape beats `$T.any()`.
618
+ const pSchema = p.schema ?? Object.values(p.content ?? {})[0]?.schema
619
+ if ((p as any).allowEmptyValue)
620
+ warn(`parameter '${p.name}': allowEmptyValue is not modelled (OpenAPI deprecates it) and is dropped`)
621
+ // Only a serialization Galbe's parsers do not implement is worth reporting.
622
+ // A query array accepts the repeated *and* the comma form, so both explode
623
+ // variants of 'form' are honoured; 'pipeDelimited' and 'spaceDelimited'
624
+ // become the array's `split`, and 'deepObject' is an object parameter.
625
+ const defaultStyle = p.in === 'query' || p.in === 'cookie' ? 'form' : 'simple'
626
+ const style = ((p as any).style as string | undefined) ?? defaultStyle
627
+ const pType = (p.schema as OpenAPIV3.SchemaObject | undefined)?.type
628
+ const split = style === 'pipeDelimited' ? '|' : style === 'spaceDelimited' ? ' ' : undefined
629
+ const honoured =
630
+ style === defaultStyle
631
+ ? p.in !== 'query' || pType !== 'object' // form-on-object is `a,b,c,d`, not implemented
632
+ : p.in === 'query' && (style === 'deepObject' ? pType === 'object' : !!split && pType === 'array')
633
+ if (!honoured)
634
+ warn(
635
+ `parameter '${p.name}': style '${style}'${pType ? ` on a ${pType}` : ''} is not implemented — the generated route parses it as '${defaultStyle}'`
636
+ )
637
+ if (p.content && Object.keys(p.content).length > 1)
638
+ warn(`parameter '${p.name}': only the first of ${Object.keys(p.content).length} content media types is kept`)
389
639
  sp[p.in][p.name] = o(
390
- unref(parseOapiSchema(p.schema, { description: p.description }), m => {
391
- let l = m.split('/')
392
- imports[l[l.length - 1]] = m
393
- return l[l.length - 1]
394
- })
640
+ deref(parseOapiSchema(pSchema, { description: p.description, deprecated: p.deprecated }, { split }))
395
641
  )
396
642
  }
397
643
 
398
- let [schemaParams, schemaQuery, schemaHeaders] = [
644
+ let [schemaParams, schemaQuery, schemaHeaders, schemaCookies] = [
399
645
  { g: 'params', o: 'path' },
400
646
  { g: 'query', o: 'query' },
401
647
  { g: 'headers', o: 'header' },
648
+ { g: 'cookies', o: 'cookie' },
402
649
  ].map(({ g, o }) =>
403
650
  Object.keys(sp[o]).length
404
651
  ? ` ${g}: {${Object.entries(sp[o])
@@ -411,11 +658,7 @@ const parseEndpointDef = (
411
658
  if (!['get', 'delete', 'options', 'head'].includes(method)) {
412
659
  let _rb = def?.requestBody as OpenAPIV3.ReferenceObject
413
660
  if (_rb?.$ref) {
414
- body = unref(` body: %ref:${_rb.$ref}%`, m => {
415
- let l = m.split('/')
416
- imports[l[l.length - 1]] = m
417
- return l[l.length - 1]
418
- })
661
+ body = deref(` body: %ref:${_rb.$ref}%`)
419
662
  } else {
420
663
  let rb = def?.requestBody as OpenAPIV3.RequestBodyObject
421
664
  let o = (s: string) => (!rb?.required ? `$T.optional(${s})` : s)
@@ -423,15 +666,17 @@ const parseEndpointDef = (
423
666
  ...new Set(
424
667
  Object.entries(rb?.content || { null: {} }).map(([media, v]) => [
425
668
  media,
426
- unref(parseOapiSchema(v.schema, undefined, { media }), m => {
427
- let l = m.split('/')
428
- imports[l[l.length - 1]] = m
429
- return l[l.length - 1]
430
- }),
669
+ deref(parseOapiSchema(v.schema, undefined, { media, encoding: (v as any).encoding })),
431
670
  ])
432
671
  ),
433
672
  ]
434
- body = bs.length ? ` body: {${bs.map(([k, v]) => `"${k}":${o(v)}`).join(',')}}` : ''
673
+ // The body's own description sits beside the media types, never spread
674
+ // onto a body schema — a spread over a schema carrying an `id` leaks it
675
+ // into the shared component (same failure mode as response metadata).
676
+ const parts = bs.map(([k, v]) => `"${k}":${o(v)}`)
677
+ if (typeof rb?.description === 'string' && rb.description)
678
+ parts.push(`description:${JSON.stringify(rb.description)}`)
679
+ body = parts.length ? ` body: {${parts.join(',')}}` : ''
435
680
  }
436
681
  }
437
682
 
@@ -439,41 +684,38 @@ const parseEndpointDef = (
439
684
  let r = def?.responses
440
685
  let rs = Object.fromEntries(
441
686
  Object.entries(r || {}).map(([status, sv]) => {
442
- let s: string = Number.isInteger(Number(status)) ? status : 'default'
687
+ // `1XX`…`5XX` are status keys of their own — Galbe carries them verbatim.
688
+ // Anything else that is not an integer collapses onto `default`.
689
+ let s: string = Number.isInteger(Number(status))
690
+ ? status
691
+ : /^[1-5]XX$/i.test(status)
692
+ ? status.toUpperCase()
693
+ : 'default'
694
+ if (s === 'default' && status !== 'default')
695
+ warn(`response '${status}' is not a status Galbe can express and collapses onto 'default'`)
443
696
 
444
697
  //@ts-ignore
445
- let rootRef = sv?.$ref
446
- ? unref(parseOapiSchema(sv), m => {
447
- let l = m.split('/')
448
- imports[l[l.length - 1]] = m
449
- return l[l.length - 1]
450
- })
451
- : null
698
+ let rootRef = sv?.$ref ? deref(parseOapiSchema(sv)) : null
452
699
  if (rootRef) return [s, rootRef]
453
700
 
454
701
  const respObj = sv as OpenAPIV3.ResponseObject
455
702
  const content = respObj?.content || {}
456
703
 
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 => {
465
- let l = m.split('/')
466
- imports[l[l.length - 1]] = m
467
- return l[l.length - 1]
468
- }
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
704
+ const headerEntries = responseHeaderEntries(respObj?.headers, components).map(
705
+ ([hName, code]) => `${JSON.stringify(hName)}:${deref(code)}`
706
+ )
707
+ const description =
708
+ typeof respObj?.description === 'string' && respObj.description ? respObj.description : undefined
709
+ const links = resolveLinks((respObj as any)?.links, components)
710
+ const hasLinks = Object.keys(links).length > 0
473
711
 
474
712
  if (Object.keys(content).length === 0) {
475
713
  let nullSchema = description ? `$T.null({description:${JSON.stringify(description)}})` : `$T.null()`
476
- if (headerEntries.length) nullSchema = `({...${nullSchema}, responseHeaders:{${headerEntries.join(',')}}})`
714
+ const extras = [
715
+ ...(headerEntries.length ? [`responseHeaders:{${headerEntries.join(',')}}`] : []),
716
+ ...(hasLinks ? [`responseLinks:${JSON.stringify(links)}`] : []),
717
+ ]
718
+ if (extras.length) nullSchema = `({...${nullSchema}, ${extras.join(', ')}})`
477
719
  return [s, nullSchema]
478
720
  }
479
721
 
@@ -485,13 +727,10 @@ const parseEndpointDef = (
485
727
  const galbeKey = mediaType
486
728
  if ((tv as any).example !== undefined && singleExample === undefined) singleExample = (tv as any).example
487
729
  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)}`)
730
+ for (const [k, ex] of Object.entries(tv.examples))
731
+ exampleParts.push(`${JSON.stringify(k)}:${JSON.stringify(resolveExample(ex, components))}`)
489
732
  }
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
- })
733
+ const schemaStr = deref(parseOapiSchema(tv.schema))
495
734
  if (!keyGroups[galbeKey]) keyGroups[galbeKey] = []
496
735
  keyGroups[galbeKey].push(schemaStr)
497
736
  }
@@ -507,6 +746,7 @@ const parseEndpointDef = (
507
746
  }
508
747
  if (description) parts.push(`description: ${JSON.stringify(description)}`)
509
748
  if (headerEntries.length) parts.push(`responseHeaders: {${headerEntries.join(',')}}`)
749
+ if (hasLinks) parts.push(`responseLinks: ${JSON.stringify(links)}`)
510
750
  if (exampleParts.length) parts.push(`examples: {${exampleParts.join(',')}}`)
511
751
  if (singleExample !== undefined) parts.push(`example: ${JSON.stringify(singleExample)}`)
512
752
  return [s, `{${parts.join(',')}}`]
@@ -514,17 +754,18 @@ const parseEndpointDef = (
514
754
  // Single body key → STResponseContent object (preserves exact media type key)
515
755
  const [key] = uniqueKeys
516
756
  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(',')}})`
757
+ let schemaStr =
758
+ unique.length === 0 ? `$T.null()` : unique.length === 1 ? unique[0] : `$T.union([${unique.join(',')}])`
759
+ // Examples sit beside the body in the content map, never spread onto the
760
+ // body schema: a spread carrying `example`/`examples` over a schema with
761
+ // an `id` leaks them into that shared component (see _responseId above).
524
762
  const parts: string[] = []
525
763
  if (key) parts.push(`"${key}":${schemaStr}`)
526
764
  if (description) parts.push(`description:${JSON.stringify(description)}`)
527
765
  if (headerEntries.length) parts.push(`responseHeaders:{${headerEntries.join(',')}}`)
766
+ if (hasLinks) parts.push(`responseLinks:${JSON.stringify(links)}`)
767
+ if (exampleParts.length) parts.push(`examples:{${exampleParts.join(',')}}`)
768
+ if (singleExample !== undefined) parts.push(`example:${JSON.stringify(singleExample)}`)
528
769
  return [s, `{${parts.join(',')}}`]
529
770
  }
530
771
  })
@@ -533,11 +774,12 @@ const parseEndpointDef = (
533
774
  if (Object.keys(rs).length) {
534
775
  resp = ` response: {${Object.entries(rs)
535
776
  .filter(([_, v]) => v)
536
- .map(([s, v]) => `${s}: ${v}`)
777
+ // a range key is not a valid bare property name — `5XX:` does not parse
778
+ .map(([s, v]) => `${/^\d+$/.test(s) ? s : JSON.stringify(s)}: ${v}`)
537
779
  .join(',')}}`
538
780
  } else resp = ''
539
781
 
540
- let schema = [schemaHeaders, schemaParams, schemaQuery, body, resp].filter(s => s)
782
+ let schema = [schemaHeaders, schemaParams, schemaQuery, schemaCookies, body, resp].filter(s => s)
541
783
 
542
784
  return {
543
785
  schema: {
@@ -556,18 +798,35 @@ const parseEndpoints = (def: OpenAPIV3.Document) => {
556
798
  let endpoints: Record<string, EndpointEntry> = {}
557
799
  for (let [fullPath, pathVal] of Object.entries(def.paths || {})) {
558
800
  if (!pathVal) continue
559
- let match = fullPath.match(/^\/?(?:(v\d+)[^\/]*\/)?(?:\/?(public|private))?\/?([^\/]+)\/?(.*)$/)
560
- if (!match) continue
561
- let [_, version, visibility, scope, path] = [...match]
562
- path = `/${path}`
801
+ // Directory convention: the leading literal segments pick the output file
802
+ // (all but the last become its directory, i.e. its dirPrefix), and the
803
+ // emitted path is written relative to that directory so the analyzer
804
+ // reconstructs the full path on load.
805
+ const segments = fullPath.split('/').filter(s => s !== '')
806
+ let nLit = 0
807
+ while (nLit < segments.length && !/[{}]/.test(segments[nLit]!)) nLit++
808
+ const scope = segments.slice(0, nLit)
809
+ const emitPath = `/${segments.slice(Math.max(nLit - 1, 0)).join('/')}`
563
810
  let methods = ['get', 'put', 'patch', 'post', 'delete', 'options', 'head'] as const
811
+ // 'trace' is deliberately absent: it is disabled across most infrastructure
812
+ // and Galbe has no builder for it. Say so rather than dropping it silently.
813
+ for (const m of Object.keys(pathVal))
814
+ if (
815
+ !(methods as readonly string[]).includes(m) &&
816
+ m !== 'parameters' &&
817
+ m !== 'summary' &&
818
+ m !== 'description' &&
819
+ m !== 'servers'
820
+ )
821
+ warn(
822
+ `method '${m.toUpperCase()}' has no Galbe route builder — the operation is skipped`,
823
+ `${m.toUpperCase()} ${fullPath}`
824
+ )
564
825
  let pathParams = pathVal.parameters || []
565
826
  for (let m of methods) {
566
827
  let endpointDef = pathVal?.[m]
567
828
  if (!endpointDef) continue
568
- let ref = `#/paths${version ? `/${version}` : ''}${visibility ? `/${visibility}` : ''}${
569
- scope ? `/${scope}` : ''
570
- }/${m}${path}`
829
+ let ref = `#/paths/${m}${fullPath}`
571
830
  let opParams = endpointDef.parameters || []
572
831
  let opKeys = new Set(
573
832
  opParams
@@ -583,13 +842,11 @@ const parseEndpoints = (def: OpenAPIV3.Document) => {
583
842
  return !opKeys.has(`${resolved.in}:${resolved.name}`)
584
843
  })
585
844
  let mergedDef = { ...endpointDef, parameters: [...inheritedParams, ...opParams] }
586
- let { schema, endpoint } = parseEndpointDef(m, fullPath, mergedDef, def.components)
845
+ let { schema, endpoint } = parseEndpointDef(m, fullPath, emitPath, mergedDef, def.components)
587
846
  endpoints[ref] = {
588
- version,
589
- visibility: visibility as 'public' | 'private',
590
847
  method: m,
591
848
  scope,
592
- path,
849
+ path: emitPath,
593
850
  schema,
594
851
  endpoint,
595
852
  }
@@ -616,7 +873,9 @@ const renderComponentSchemaFile = (
616
873
  let depMatch = [...s.dependsOn][0].match(/^#\/components\/([^\/]+)\/([^\/]+)/)
617
874
  if (!depMatch) return
618
875
  let [_, depOrig, depName] = [...depMatch]
619
- decl.push(`export { ${depName} } from './${COMPONENT_TYPE_MAP[depOrig as keyof typeof COMPONENT_TYPE_MAP]}.schema'\n`)
876
+ decl.push(
877
+ `export { ${depName} } from './${COMPONENT_TYPE_MAP[depOrig as keyof typeof COMPONENT_TYPE_MAP]}.schema'\n`
878
+ )
620
879
  return
621
880
  }
622
881
  for (let dep of [k, ...s.dependsOn]) {
@@ -628,32 +887,38 @@ const renderComponentSchemaFile = (
628
887
  imports[depOrig].push(depName)
629
888
  }
630
889
  }
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 {
655
- decl.push(`export const ${s.key} = ${s.schema}\nexport type ${s.key} = Static<typeof ${s.key}>\n`)
890
+ // Responses and requestBodies are emitted in the content-map form: media
891
+ // types as keys, the component's own metadata beside them, and an
892
+ // `_responseId` / `_requestBodyId` preserving the component's name so the
893
+ // serializer can put it back under `components` and `$ref` it.
894
+ //
895
+ // Never a spread onto the body schema: that writes the response's
896
+ // `description`/`example` into the shared component schema the body refers
897
+ // to, which the serializer registers by `id`.
898
+ if (type === 'responses' || type === 'requestBodies') {
899
+ const isResp = type === 'responses'
900
+ const content = (isResp ? s.responseContent : s.requestContent) ?? []
901
+ const extras = [`${isResp ? '_responseId' : '_requestBodyId'}: "${s.key}"`]
902
+ const description = isResp ? s.responseDescription : s.requestDescription
903
+ if (description) extras.push(`description: ${JSON.stringify(description)}`)
904
+ if (isResp) {
905
+ if (s.responseExample !== undefined) extras.push(`example: ${JSON.stringify(s.responseExample)}`)
906
+ if (s.responseExamples) extras.push(`examples: ${JSON.stringify(s.responseExamples)}`)
907
+ if (s.responseLinks) extras.push(`responseLinks: ${JSON.stringify(s.responseLinks)}`)
908
+ if (s.responseHeaders?.length)
909
+ extras.push(
910
+ `responseHeaders: {${s.responseHeaders.map(([n, sc]) => `${JSON.stringify(n)}: ${sc}`).join(', ')}}`
911
+ )
912
+ } else if (s.requestRequired !== undefined) extras.push(`required: ${s.requestRequired}`)
913
+ const body = [...content.map(([media, sc]) => `${JSON.stringify(media)}: ${sc}`), ...extras].join(', ')
914
+ // the exported type is the body type, read back off the const
915
+ const bodyType = content.length
916
+ ? content.map(([media]) => `Static<(typeof ${s.key})[${JSON.stringify(media)}]>`).join(' | ')
917
+ : 'null'
918
+ decl.push(`export const ${s.key} = { ${body} }\nexport type ${s.key} = ${bodyType}\n`)
919
+ return
656
920
  }
921
+ decl.push(`export const ${s.key} = ${s.schema}\nexport type ${s.key} = Static<typeof ${s.key}>\n`)
657
922
  })
658
923
  if (decl.length === 0) return ''
659
924
  return `import type { Static } from 'galbe/schema'\nimport { $T } from 'galbe'\n${Object.entries(imports)
@@ -667,7 +932,7 @@ const renderComponentSchemaFile = (
667
932
  export type RoutePlanEntry = {
668
933
  /** HTTP method, lowercase (matches the property accessed on `g`). */
669
934
  method: string
670
- /** Path as emitted in the call expression: full prefix included, OpenAPI braces converted to `:param`. Stable identity for diff/rename. */
935
+ /** Path as emitted in the call expression: relative to the scope's `prefix`, OpenAPI braces converted to `:param`. */
671
936
  path: string
672
937
  schemaName: string
673
938
  /** JSDoc block string (e.g. '/**\n * summary\n *\/'). */
@@ -677,9 +942,11 @@ export type RoutePlanEntry = {
677
942
  }
678
943
 
679
944
  export type ScopePlan = {
680
- /** e.g. '/main', '/v1/public/admin'. */
945
+ /** e.g. '/main', '/v1/modules'. */
681
946
  scopeKey: string
682
- /** Output path without extension, e.g. 'routes/main.route'. */
947
+ /** The route file's directory prefix (dirPrefix reconstructs it on load), e.g. '/v1' or ''. */
948
+ prefix: string
949
+ /** Output path without extension, e.g. 'main.route', 'v1/modules.route'. */
683
950
  routeFile: string
684
951
  /** Output path without extension, e.g. 'schemas/main.schema'. */
685
952
  schemaFile: string
@@ -697,6 +964,8 @@ export type GenerationPlan = {
697
964
  componentFiles: { path: string; content: string }[]
698
965
  scopes: ScopePlan[]
699
966
  target: 'js' | 'ts'
967
+ /** Constructs the spec declared that the generated sources cannot carry. */
968
+ warnings?: GenerationWarning[]
700
969
  }
701
970
 
702
971
  export const buildPlan = (
@@ -725,9 +994,7 @@ export const buildPlan = (
725
994
  }
726
995
 
727
996
  let scopedDefs = Object.entries(endpoints).reduce<Record<string, EndpointEntry[]>>((p, [_, v]) => {
728
- let scopeKey = `${v.version ? `/${v.version}` : ''}${v.visibility ? `/${v.visibility}` : ''}${
729
- v.scope ? `/${v.scope}` : '/main'
730
- }`
997
+ let scopeKey = v.scope.length ? `/${v.scope.join('/')}` : '/main'
731
998
  if (!(scopeKey in p)) p[scopeKey] = []
732
999
  p[scopeKey].push(v)
733
1000
  return p
@@ -735,7 +1002,9 @@ export const buildPlan = (
735
1002
 
736
1003
  const scopes: ScopePlan[] = []
737
1004
  for (let [scopeKey, def] of Object.entries(scopedDefs)) {
738
- let routeFile = `routes${scopeKey}.route`
1005
+ const scope = def[0].scope
1006
+ let prefix = scope.length > 1 ? `/${scope.slice(0, -1).join('/')}` : ''
1007
+ let routeFile = scope.length ? `${scope.join('/')}.route` : 'main.route'
739
1008
  let schemaFile = `schemas${scopeKey}.schema`
740
1009
 
741
1010
  let sImports: Record<string, Set<string>> = {}
@@ -771,6 +1040,7 @@ export const buildPlan = (
771
1040
 
772
1041
  scopes.push({
773
1042
  scopeKey,
1043
+ prefix,
774
1044
  routeFile,
775
1045
  schemaFile,
776
1046
  schemaImports: Object.fromEntries(Object.entries(sImports).map(([k, v]) => [k, [...v]])),
@@ -783,6 +1053,12 @@ export const buildPlan = (
783
1053
  return { componentFiles, scopes, target }
784
1054
  }
785
1055
 
1056
+ /** Relative import path from a scope's route file to its sibling schema file. */
1057
+ export const schemaImportPath = (scope: Pick<ScopePlan, 'routeFile' | 'schemaFile'>): string => {
1058
+ const rel = relative(dirname(scope.routeFile), scope.schemaFile)
1059
+ return rel.startsWith('.') ? rel : `./${rel}`
1060
+ }
1061
+
786
1062
  export type ApplyPlanOptions = {
787
1063
  /** Override the route file content for given scope keys. When set, the value is written verbatim
788
1064
  * instead of fresh-rendering from the plan — used by the merger to preserve user code. */
@@ -813,8 +1089,7 @@ export const applyPlan = async (plan: GenerationPlan, outDir: string, opts: Appl
813
1089
  if (override !== undefined) {
814
1090
  routeContent = override
815
1091
  } else {
816
- const deepness = scope.scopeKey.split('/').length - 1
817
- const importPath = `${Array(deepness).fill('../').join('')}schemas${scope.scopeKey}.schema`
1092
+ const importPath = schemaImportPath(scope)
818
1093
  const rDecl = scope.routes.map(r => ` ${r.meta}\ng.${r.call}`)
819
1094
  routeContent =
820
1095
  `import { NotImplementedError, type Galbe } from 'galbe'\n` +
@@ -838,10 +1113,12 @@ export const planFromOapi = async (
838
1113
  let v = def?.openapi
839
1114
  if (!v || !semver.satisfies(v, version)) throw new Error('Invalid openapi version')
840
1115
 
1116
+ warnings = []
1117
+ warnAt = ''
841
1118
  let schemaIndex = buildSchemaIndex(def)
842
1119
  let endpointDefs = parseEndpoints(def)
843
1120
 
844
- return buildPlan(endpointDefs, schemaIndex, target)
1121
+ return { ...buildPlan(endpointDefs, schemaIndex, target), warnings }
845
1122
  }
846
1123
 
847
1124
  export const generateFromOapi = async (