galbe 0.15.6 → 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.
@@ -12,10 +12,17 @@ import type {
12
12
  } from '../../../src/schema'
13
13
 
14
14
  import { Galbe } from '../../../src'
15
- import { walkRoutes, HttpStatus } from '../../../src/util'
15
+ import {
16
+ walkRoutes,
17
+ HttpStatus,
18
+ matchMiddleware,
19
+ parseMiddlewarePattern,
20
+ routeHead,
21
+ RESPONSE_RANGE_DESCRIPTION,
22
+ } from '../../../src/util'
16
23
  import { Kind, Optional } from '../../../src/schema'
17
24
 
18
- import { OpenAPIV3 } from 'openapi-types'
25
+ import type { OpenAPIV3 } from 'openapi-types'
19
26
 
20
27
  type SchemaType = { type: string; format: string; isJson: boolean }
21
28
 
@@ -28,15 +35,56 @@ const schemaToMedia = ({ type, format, isJson }: SchemaType, hasComposite = fals
28
35
  ? 'text/plain'
29
36
  : 'application/json'
30
37
 
38
+ /**
39
+ * `{type, enum}` for a set of literals that share one primitive type — the
40
+ * idiomatic OpenAPI spelling for both a lone literal and a union of them.
41
+ * `STLiteral` accepts `string | number | boolean`, so the type is read off the
42
+ * value rather than assumed to be `string`. Returns null for a mixed-type set,
43
+ * which has to stay in its `anyOf`/`oneOf` form.
44
+ */
45
+ const literalEnum = (members: STSchema[]): { type: string; enum: any[] } | null => {
46
+ if (!members.length || !members.every(m => m[Kind] === 'literal')) return null
47
+ const values = members.map(m => (m as STLiteral).value)
48
+ const types = new Set(values.map(v => typeof v))
49
+ if (types.size !== 1) return null
50
+ switch ([...types][0]) {
51
+ case 'string':
52
+ return { type: 'string', enum: values }
53
+ case 'boolean':
54
+ return { type: 'boolean', enum: values }
55
+ case 'number':
56
+ return { type: values.every(v => Number.isInteger(v)) ? 'integer' : 'number', enum: values }
57
+ default:
58
+ return null
59
+ }
60
+ }
61
+
31
62
  export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<OpenAPIV3.Document> => {
63
+ // OpenAPI 3.0 schemas follow JSON Schema draft-4, where `exclusiveMinimum` /
64
+ // `exclusiveMaximum` are booleans modifying `minimum` / `maximum`. 3.1 (JSON
65
+ // Schema 2020-12) makes them the numeric bound itself.
66
+ const draft4Bounds = version.startsWith('3.0')
32
67
  let paths: any = {}
33
68
  let components: OpenAPIV3.ComponentsObject = {
34
- securitySchemes: {},
69
+ securitySchemes: { ...g.config?.openapi?.securitySchemes },
35
70
  schemas: {},
36
71
  parameters: {},
37
72
  requestBodies: {},
38
73
  responses: {},
39
74
  }
75
+ // A scheme the app declared is authoritative: a middleware def may not
76
+ // redefine a name the config already owns.
77
+ const declaredSchemes = new Set(Object.keys(g.config?.openapi?.securitySchemes ?? {}))
78
+
79
+ // A middleware def may define the scheme it enforces, not just name it: an
80
+ // `apiKey` or `basic` middleware is not expressible as a name alone.
81
+ for (const m of g.middlewares) {
82
+ for (const [name, scheme] of Object.entries(m.securitySchemes ?? {})) {
83
+ if (declaredSchemes.has(name)) continue
84
+ components.securitySchemes![name] = scheme
85
+ declaredSchemes.add(name)
86
+ }
87
+ }
40
88
 
41
89
  const schemaToOpenapi = (
42
90
  schema: STSchema
@@ -58,12 +106,34 @@ export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<Op
58
106
  else if (kind === 'byteArray') s = { type: 'string', format: 'binary' }
59
107
  else if (kind === 'number' || kind === 'integer') {
60
108
  const n = schema as STNumber | STInteger
109
+ // An exclusive bound wins over an inclusive one on the same side: draft-4
110
+ // has a single `minimum`/`maximum` slot, and the exclusive form is the
111
+ // one the parser emits when the source spec marked the bound exclusive.
112
+ const lower =
113
+ n.exclusiveMin !== undefined
114
+ ? { value: n.exclusiveMin, exclusive: true }
115
+ : n.min !== undefined
116
+ ? { value: n.min, exclusive: false }
117
+ : undefined
118
+ const upper =
119
+ n.exclusiveMax !== undefined
120
+ ? { value: n.exclusiveMax, exclusive: true }
121
+ : n.max !== undefined
122
+ ? { value: n.max, exclusive: false }
123
+ : undefined
124
+ const bound = (b: typeof lower, key: 'minimum' | 'maximum') => {
125
+ if (!b) return {}
126
+ if (!b.exclusive) return { [key]: b.value }
127
+ return draft4Bounds
128
+ ? { [key]: b.value, [`exclusive${key[0]!.toUpperCase()}${key.slice(1)}`]: true }
129
+ : { [`exclusive${key[0]!.toUpperCase()}${key.slice(1)}`]: b.value }
130
+ }
61
131
  s = {
62
132
  type: kind,
63
- ...(n.exclusiveMin !== undefined ? { exclusiveMinimum: n.exclusiveMin } : {}),
64
- ...(n.exclusiveMax !== undefined ? { exclusiveMaximum: n.exclusiveMax } : {}),
65
- ...(n.min !== undefined ? { minimum: n.min } : {}),
66
- ...(n.max !== undefined ? { maximum: n.max } : {}),
133
+ ...(n.format ? { format: n.format } : {}),
134
+ ...bound(lower, 'minimum'),
135
+ ...bound(upper, 'maximum'),
136
+ ...(n.multipleOf !== undefined ? { multipleOf: n.multipleOf } : {}),
67
137
  }
68
138
  } else if (kind === 'string') {
69
139
  const str = schema as STString
@@ -78,8 +148,7 @@ export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<Op
78
148
  }
79
149
  } else if (kind === 'any') s = {}
80
150
  else if (kind === 'literal') {
81
- let value = (schema as STLiteral).value
82
- s = { type: 'string', enum: [value] }
151
+ s = literalEnum([schema]) ?? {}
83
152
  } else if (kind === 'array') {
84
153
  const arr = schema as STArray
85
154
  // ArrayOptions exposes minLength/maxLength (matching the schema-builder API);
@@ -96,10 +165,17 @@ export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<Op
96
165
  let required = Object.entries(props)
97
166
  .filter(([_, v]) => !v?.[Optional])
98
167
  .map(([k, _]) => k)
168
+ // `$T.object()` with no props means "any object"; `properties: {}` reads
169
+ // to most tooling as "an object with no known properties" instead, so an
170
+ // empty map is omitted rather than emitted.
171
+ const ap = (schema as STObject).additionalProperties
99
172
  s = {
100
173
  type: 'object',
101
- properties: Object.fromEntries(Object.entries(props).map(([k, v]) => [k, schemaToOpenapi(v).schema])),
174
+ ...(Object.keys(props).length
175
+ ? { properties: Object.fromEntries(Object.entries(props).map(([k, v]) => [k, schemaToOpenapi(v).schema])) }
176
+ : {}),
102
177
  ...(required.length ? { required } : {}),
178
+ ...(ap === undefined ? {} : { additionalProperties: ap === false ? false : schemaToOpenapi(ap).schema }),
103
179
  }
104
180
  } else if (kind === 'json') {
105
181
  const inner = (schema as STJson).value as STSchema | undefined
@@ -110,16 +186,18 @@ export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<Op
110
186
  let nullable = members.some(s => s[Kind] === 'null')
111
187
  members = members.filter(s => s[Kind] !== 'null')
112
188
 
113
- const allStringLiterals =
114
- members.length > 0 && members.every(e => e[Kind] === 'literal' && typeof (e as STLiteral).value === 'string')
115
189
  const useOneOf = kind === 'oneOf'
190
+ // `oneOf` keeps its explicit form — the author asked for "exactly one of
191
+ // these", and an `enum` does not say that. `anyOf` over literals is just
192
+ // a closed value set, which is what `enum` means.
193
+ const asEnum = useOneOf ? null : literalEnum(members)
116
194
 
117
195
  if (members.length === 0) {
118
196
  s = {}
119
197
  } else if (members.length === 1) {
120
198
  s = schemaToOpenapi(members[0]!).schema
121
- } else if (allStringLiterals && !useOneOf) {
122
- s = { type: 'string', enum: members.map(e => (e as STLiteral).value) }
199
+ } else if (asEnum) {
200
+ s = asEnum
123
201
  } else if (members.length > 1) {
124
202
  const variants = members.map(e => schemaToOpenapi(e).schema)
125
203
  s = useOneOf ? { oneOf: variants } : { anyOf: variants }
@@ -146,6 +224,9 @@ export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<Op
146
224
  ...s,
147
225
  ...(schema?.default !== undefined ? { default: schema.default } : {}),
148
226
  ...(schema?.examples !== undefined ? { example: schema.examples } : {}),
227
+ // documentation-only access annotations, valid on any schema
228
+ ...(schema?.readOnly ? { readOnly: true } : {}),
229
+ ...(schema?.writeOnly ? { writeOnly: true } : {}),
149
230
  }
150
231
  if (components.schemas && schema.id) {
151
232
  components.schemas[schema.id] = s
@@ -167,39 +248,105 @@ export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<Op
167
248
  }, components[kind])
168
249
  }
169
250
 
251
+ /**
252
+ * The `style`/`explode` pair, emitted only where Galbe's parser does something
253
+ * other than the OpenAPI default. A query array accepts both the repeated and
254
+ * the comma form, which is the default pair — nothing to say. A custom
255
+ * `split` is `pipeDelimited`/`spaceDelimited`, and an object query parameter
256
+ * is `deepObject`, both of which the parser implements.
257
+ */
258
+ const paramSerialization = (param: STSchema, kind: 'query' | 'header' | 'path' | 'cookie') => {
259
+ if (kind !== 'query') return {}
260
+ if (param[Kind] === 'object') return { style: 'deepObject', explode: true }
261
+ if (param[Kind] === 'array') {
262
+ const split = (param as STArray).split
263
+ if (split === '|') return { style: 'pipeDelimited' }
264
+ if (split === ' ') return { style: 'spaceDelimited' }
265
+ }
266
+ return {}
267
+ }
268
+
170
269
  const parseParam = (key: string, param: STSchema, kind: 'query' | 'header' | 'path' | 'cookie') => {
171
270
  let { schema } = schemaToOpenapi({ ...param, [Optional]: false })
271
+ // A Galbe schema has one `description`, which is where a parameter's own
272
+ // description lives. It belongs on the Parameter Object, so lift it and
273
+ // drop the copy the schema serializer emitted (same as response headers).
274
+ if ((schema as any)?.description) delete (schema as any).description
172
275
  let p: OpenAPIV3.ParameterObject = {
173
276
  name: key,
174
277
  in: kind,
175
278
  description: param?.description,
176
279
  required: kind === 'path' ? true : !param[Optional] || undefined,
177
280
  deprecated: param.deprecated,
281
+ ...paramSerialization(param, kind),
178
282
  schema,
179
283
  }
180
284
  return p
181
285
  }
182
286
 
183
- const metaRoutes = g.meta?.reduce(
184
- (routes, c) => ({ ...routes, ...c.routes }),
185
- {} as Record<string, Record<string, Record<string, any>>>
186
- )
187
- let metaStatic = Object.fromEntries(Object.entries(metaRoutes || {}).filter(([_, d]) => d?.static))
287
+ const metaRoutes: Record<string, Record<string, Record<string, any>>> = {}
288
+ // A route file's own header meta applies to every route the file declares —
289
+ // the same contract a middleware file's header has over its scope. Kept
290
+ // beside the route meta rather than merged into it so route-level metadata
291
+ // can still win.
292
+ const metaFileHeaders: Record<string, Record<string, any>> = {}
293
+ for (const c of g.meta ?? []) {
294
+ for (const [routePath, methods] of Object.entries(c.routes ?? {})) {
295
+ metaRoutes[routePath] = { ...metaRoutes[routePath], ...methods }
296
+ if (c.header && Object.keys(c.header).length) metaFileHeaders[routePath] = c.header
297
+ }
298
+ }
299
+ let metaStatic = Object.fromEntries(Object.entries(metaRoutes).filter(([_, d]) => d?.static))
300
+
301
+ // middleware-file header meta applies to every operation in the file's scope
302
+ const mwMeta = (g.metaMiddleware ?? [])
303
+ .filter(m => m.header && Object.keys(m.header).length)
304
+ .map(m => ({ segments: parseMiddlewarePattern(m.scope), header: m.header }))
305
+
306
+ // Middleware-scope security, from both ways a middleware can carry it: a
307
+ // middleware file's `@security` header and a def's `security` field. One rung
308
+ // of the precedence chain, so a packaged middleware documents itself the same
309
+ // whether it was registered in code or discovered as a file.
310
+ const hasSecurity = (s: unknown) => s !== undefined && !(Array.isArray(s) && !s.length)
311
+ // Nearest scope wins: a longer pattern is more specific, and an exact pattern
312
+ // beats a subtree wildcard of the same length. Ties keep declaration order,
313
+ // which puts a file's `@security` header ahead of the def it annotates — the
314
+ // annotation is the app's own word on a middleware it may not own.
315
+ const scopeRank = (s: string[]) => s.length * 2 + (s[s.length - 1] === '*' ? 0 : 1)
316
+ const mwSecurity = [
317
+ ...mwMeta
318
+ .filter(m => hasSecurity(m.header.security))
319
+ .map(m => ({ segments: m.segments, security: m.header.security })),
320
+ ...g.middlewares.filter(m => hasSecurity(m.security)).map(m => ({ segments: m.segments, security: m.security! })),
321
+ ].sort((a, b) => scopeRank(b.segments) - scopeRank(a.segments))
322
+
323
+ // meta keys and spec paths are relative to basePath; route paths carry it
324
+ const prefix = g.router.prefix || ''
325
+ const relPath = (p: string) => (prefix && p.startsWith(prefix) ? p.slice(prefix.length) || '/' : p)
188
326
 
189
327
  walkRoutes(g.router.routes, r => {
190
- let meta = metaRoutes?.[r.path]?.[r.method]
328
+ const rPath = relPath(r.path)
329
+ let meta = metaRoutes?.[rPath]?.[r.method]
330
+ const fileHeader = metaFileHeaders[rPath]
191
331
  if (r.static?.root) meta = metaStatic[r.static?.root]?.static
192
332
  if (meta?.hide) return
193
- let path = r.path.replaceAll(/:([^\/]+)/g, '{$1}')
333
+ const rSegments = rPath.split('/').filter(s => s !== '')
334
+ const inherited = mwMeta.filter(m => matchMiddleware(m.segments, rSegments))
335
+ let path = rPath.replaceAll(/:([^\/]+)/g, '{$1}')
194
336
  if (!(path in paths)) paths[path] = {}
195
- let tags = [
196
- ...(meta?.tags?.split(' ')?.map((t: string) => t.trim()) || []),
197
- ...(typeof meta?.tag === 'string' ? [meta?.tag] : meta?.tag || []),
337
+ const metaTags = (m?: Record<string, any>) => [
338
+ ...(m?.tags?.split?.(' ')?.map((t: string) => t.trim()) || []),
339
+ ...(typeof m?.tag === 'string' ? [m?.tag] : m?.tag || []),
198
340
  ]
199
- let security: Record<string, any> = []
341
+ // tags accumulate from every scope that names one, nearest first
342
+ let tags = [...new Set([...metaTags(meta), ...metaTags(fileHeader), ...inherited.flatMap(m => metaTags(m.header))])]
343
+ let security: Record<string, any>[] = []
200
344
  let securityExplicitlyEmpty = false
201
345
 
202
- const metaSecRaw = meta?.security
346
+ // nearest scope wins outright: the route, then its file's header, then the
347
+ // middleware covering it — file annotation or def, already ordered
348
+ const metaSecRaw =
349
+ meta?.security ?? fileHeader?.security ?? mwSecurity.find(m => matchMiddleware(m.segments, rSegments))?.security
203
350
  if (metaSecRaw !== undefined) {
204
351
  const entries = Array.isArray(metaSecRaw) ? metaSecRaw : [metaSecRaw]
205
352
  for (const e of entries) {
@@ -217,63 +364,74 @@ export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<Op
217
364
  }
218
365
  }
219
366
 
367
+ // A scheme says which request field carries the credential, so the schema's
368
+ // own parameter for it is redundant: the operation documents it as auth, not
369
+ // as a plain header. Schemes are resolved wherever they were defined — the
370
+ // app config, a middleware def, or the bearerAuth default just above.
371
+ const credentialParams = new Set<string>()
372
+ for (const req of security) {
373
+ for (const name of Object.keys(req)) {
374
+ const scheme = components.securitySchemes?.[name]
375
+ if (!scheme || '$ref' in scheme) continue
376
+ if (scheme.type === 'http') credentialParams.add('header:authorization')
377
+ else if (scheme.type === 'apiKey' && scheme.name)
378
+ credentialParams.add(`${scheme.in}:${scheme.name.toLowerCase()}`)
379
+ }
380
+ }
381
+
220
382
  let pathParam = r.schema?.params
221
383
  ? Object.entries(r.schema?.params as Record<string, STSchema>).map(([k, v]) => parseParam(k, v, 'path'))
222
384
  : []
223
385
  let queryParam = r.schema?.query
224
386
  ? Object.entries(r.schema?.query as Record<string, STSchema>).map(([k, v]) => parseParam(k, v, 'query'))
225
387
  : []
226
- const metaSecuritySet = security.length > 0
227
388
  let headerParam = r.schema?.headers
228
- ? Object.entries(r.schema?.headers as Record<string, STSchema>)
229
- .map(([k, v]) => {
230
- let p = parseParam(k, v, 'header')
231
- if (k.match(/authorization/i)) {
232
- // TODO: handle other auth methods
233
- const str = v as STString
234
- if (str.pattern && str.pattern.toString() === '/^Bearer /') {
235
- if (!metaSecuritySet) security.push({ bearerAuth: [] })
236
- const scheme: OpenAPIV3.HttpSecurityScheme = { type: 'http', scheme: 'bearer' }
237
- if (typeof str.format === 'string') scheme.bearerFormat = str.format
238
- if (typeof str.description === 'string') scheme.description = str.description
239
- if (!components.securitySchemes) components.securitySchemes = {}
240
- components.securitySchemes.bearerAuth = scheme
241
- return null
242
- }
243
- }
244
- return p
245
- })
246
- .filter(p => p)
389
+ ? Object.entries(r.schema?.headers as Record<string, STSchema>).map(([k, v]) => parseParam(k, v, 'header'))
247
390
  : []
248
- // TODO cookieParam
249
- let parameters = [...pathParam, ...queryParam, ...headerParam]
391
+ let cookieParam = r.schema?.cookies
392
+ ? Object.entries(r.schema?.cookies as Record<string, STSchema>).map(([k, v]) => parseParam(k, v, 'cookie'))
393
+ : []
394
+ let parameters = [...pathParam, ...queryParam, ...headerParam, ...cookieParam]
395
+ if (credentialParams.size)
396
+ parameters = parameters.filter(p => p && !credentialParams.has(`${p.in}:${p.name.toLowerCase()}`))
250
397
 
251
398
  let requestBody
252
399
  if (r.schema.body) {
253
- let description: string | undefined
254
- let conflictDescription = false
400
+ // Like STResponseContent, a body map keys its bodies by media type; any
401
+ // other key (`description`, `required`, `_requestBodyId`) is metadata
402
+ // about the request body itself.
403
+ const bodyMap = r.schema.body as Record<string, any>
255
404
  let required = false
256
405
  let content = Object.fromEntries(
257
- Object.entries(r.schema.body).map(([bodyType, schema]) => {
258
- const s = schema.description
259
- const isDefined = typeof s === 'string' && s !== ''
260
- if (!schema?.[Optional]) required = true
261
- if (isDefined) {
262
- if (description === undefined) {
263
- description = s
264
- } else if (description !== s) {
265
- conflictDescription = true
266
- }
267
- }
268
- description = conflictDescription ? undefined : (description ?? undefined)
269
- return [bodyType, { schema: schemaToOpenapi(schema).schema }]
270
- })
406
+ Object.entries(bodyMap)
407
+ .filter(([bodyType, schema]) => bodyType.includes('/') && schema)
408
+ .map(([bodyType, schema]) => {
409
+ if (!schema?.[Optional]) required = true
410
+ // `encoding` belongs to the media type, not to the schema under it
411
+ const encoding = (schema as any)?.encoding
412
+ return [
413
+ bodyType,
414
+ {
415
+ schema: schemaToOpenapi(schema).schema,
416
+ ...(encoding && Object.keys(encoding).length ? { encoding } : {}),
417
+ },
418
+ ]
419
+ })
271
420
  )
421
+ // The body's own description comes from the body map and nowhere else.
422
+ // Deriving it from a body schema's `description` is wrong the moment the
423
+ // schema is a `$ref` to a documented component: that description belongs
424
+ // to the component, not to this operation's request body.
272
425
  requestBody = {
273
- description,
274
- required,
426
+ ...(typeof bodyMap.description === 'string' && bodyMap.description ? { description: bodyMap.description } : {}),
427
+ required: typeof bodyMap.required === 'boolean' ? bodyMap.required : required,
275
428
  content,
276
429
  }
430
+ // a body that came from components.requestBodies is emitted once and referenced
431
+ if (components.requestBodies && bodyMap._requestBodyId) {
432
+ components.requestBodies[bodyMap._requestBodyId as string] = requestBody
433
+ requestBody = { $ref: `#/components/requestBodies/${bodyMap._requestBodyId}` } as any
434
+ }
277
435
  }
278
436
  let responses
279
437
  if (r.schema.response && Object.keys(r.schema.response).length) {
@@ -281,32 +439,42 @@ export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<Op
281
439
  Object.entries(r.schema.response).map(([status, v]) => {
282
440
  if (!v) return []
283
441
  let s = status as keyof typeof HttpStatus | 'default'
442
+ // `1XX`…`5XX` are status keys in their own right in OpenAPI; they
443
+ // carry no HttpStatus reason phrase, so they get a range description.
444
+ const statusDescription = HttpStatus[s as keyof typeof HttpStatus] ?? RESPONSE_RANGE_DESCRIPTION[status]
284
445
  const isContentMap = !(v as any)[Kind]
285
446
  const explicitHeaders = (v as any)?.responseHeaders as Record<string, STSchema> | undefined
286
447
  let response: OpenAPIV3.ResponseObject
287
448
 
288
449
  if (isContentMap) {
289
- // STResponseContent — iterate body-type keys
450
+ // STResponseContent — every key holding a media type is a body; the
451
+ // rest (`description`, `example`, `examples`, `responseHeaders`,
452
+ // `_responseId`) is response-level metadata. Media types always
453
+ // contain a '/', which is what separates the two.
290
454
  const cm = v as any
291
- const desc = cm.description || HttpStatus[s as keyof typeof HttpStatus] || 'Response'
455
+ const desc = cm.description || statusDescription || 'Response'
292
456
  const content: Record<string, { schema: any; example?: any; examples?: Record<string, any> }> = {}
293
457
  for (const [key, bodySchema] of Object.entries(cm)) {
294
- if (key === 'description' || key === 'responseHeaders' || key === 'example' || key === 'examples') continue
458
+ if (!key.includes('/') || !bodySchema) continue
295
459
  const { schema: oaSchema } = schemaToOpenapi(bodySchema as STSchema)
296
460
  content[key] = { schema: oaSchema }
297
- const ex = (bodySchema as any)?.examples
298
- const exSingle = (bodySchema as any)?.example
461
+ // response-level example(s) apply to every media type offered
462
+ const ex = (bodySchema as any)?.examples ?? cm.examples
463
+ const exSingle = (bodySchema as any)?.example ?? cm.example
299
464
  if (ex && Object.keys(ex).length) content[key]!.examples = ex
300
465
  if (exSingle !== undefined) content[key]!.example = exSingle
301
466
  }
302
467
  response = { description: desc, ...(Object.keys(content).length ? { content } : {}) }
303
468
  } else {
304
- const statusNum = Number(s)
305
- const noBodyStatus = statusNum === 204 || statusNum === 304 || (statusNum >= 100 && statusNum < 200)
306
- const noContent = noBodyStatus && (v as any)[Kind] === 'null'
469
+ // A bare `null`-kind response schema means "this response has no
470
+ // body", at every status not just the ones where HTTP forbids
471
+ // one. A genuine JSON `null` body stays expressible, and reads
472
+ // unambiguously, through the content-map form
473
+ // `{'application/json': $T.null()}`.
474
+ const noContent = (v as any)[Kind] === 'null'
307
475
  if (noContent) {
308
476
  response = {
309
- description: (v as any).description || HttpStatus[s as keyof typeof HttpStatus] || 'Response',
477
+ description: (v as any).description || statusDescription || 'Response',
310
478
  }
311
479
  } else {
312
480
  let { schema, isJson } = schemaToOpenapi(v as STSchema)
@@ -316,23 +484,18 @@ export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<Op
316
484
  const mediaType = schemaToMedia({ type, format, isJson } as SchemaType, hasComposite)
317
485
  const explicitExamples = (v as any)?.examples as Record<string, any> | undefined
318
486
  const explicitExample = (v as any)?.example
319
- const content: Record<string, { schema: typeof schema; example?: any; examples?: Record<string, any> }> = {
320
- [mediaType]: { schema: { ...schema } },
321
- }
322
- if (explicitExamples && Object.keys(explicitExamples).length) content[mediaType]!.examples = explicitExamples
487
+ const content: Record<string, { schema: typeof schema; example?: any; examples?: Record<string, any> }> =
488
+ {
489
+ [mediaType]: { schema: { ...schema } },
490
+ }
491
+ if (explicitExamples && Object.keys(explicitExamples).length)
492
+ content[mediaType]!.examples = explicitExamples
323
493
  if (explicitExample !== undefined) content[mediaType]!.example = explicitExample
324
494
  response = {
325
- description: (v as any).description || HttpStatus[s as keyof typeof HttpStatus] || 'Response',
495
+ description: (v as any).description || statusDescription || 'Response',
326
496
  content,
327
497
  }
328
498
  }
329
- const respSchema = r.schema.response?.[s] as any
330
- const respId = respSchema?._responseId
331
- if (components.responses && respId) {
332
- components.responses[respId as string] = response
333
- //@ts-ignore
334
- response = { $ref: `#/components/responses/${respId}` }
335
- }
336
499
  }
337
500
 
338
501
  if (explicitHeaders && Object.keys(explicitHeaders).length) {
@@ -350,6 +513,21 @@ export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<Op
350
513
  response.headers[hName] = headerObj
351
514
  }
352
515
  }
516
+
517
+ // `links` is carried verbatim: Galbe models no operation graph, so
518
+ // there is nothing to derive it from and nothing to validate it against.
519
+ const explicitLinks = (v as any)?.responseLinks as Record<string, any> | undefined
520
+ if (explicitLinks && Object.keys(explicitLinks).length) response.links = explicitLinks
521
+
522
+ // `_responseId` marks a response that came from components.responses.
523
+ // Register the fully-built response — headers included — and refer to
524
+ // it: a Reference Object tolerates no sibling keys in 3.0.
525
+ const respId = (v as any)?._responseId
526
+ if (components.responses && respId) {
527
+ components.responses[respId as string] = response
528
+ //@ts-ignore
529
+ response = { $ref: `#/components/responses/${respId}` }
530
+ }
353
531
  return [s, response]
354
532
  })
355
533
  )
@@ -358,23 +536,21 @@ export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<Op
358
536
  default: { description: HttpStatus[200] },
359
537
  }
360
538
  }
361
- const head: string = meta?.head ?? ''
362
- let summary: string | undefined
363
- let description: string | undefined
364
- if (head) {
365
- const firstBlank = head.indexOf('\n\n')
366
- if (firstBlank === -1) {
367
- const nl = head.indexOf('\n')
368
- summary = (nl === -1 ? head : head.slice(0, nl)).trim() || undefined
369
- } else {
370
- summary = head.slice(0, firstBlank).trim() || undefined
371
- description = head.slice(firstBlank + 2).trim() || undefined
372
- }
539
+ const { summary, description } = routeHead(meta)
540
+ // `@externalDocs <url> [description]` — the url runs to the first
541
+ // whitespace, everything after it is the docs' description.
542
+ const extDocsRaw = Array.isArray(meta?.externalDocs) ? meta.externalDocs[0] : meta?.externalDocs
543
+ let externalDocs: OpenAPIV3.ExternalDocumentationObject | undefined
544
+ if (typeof extDocsRaw === 'string' && extDocsRaw.trim()) {
545
+ const [url, ...rest] = extDocsRaw.trim().split(/\s+/)
546
+ const docsDescription = rest.join(' ')
547
+ externalDocs = { url: url!, ...(docsDescription ? { description: docsDescription } : {}) }
373
548
  }
374
549
  paths[path][r.method] = {
375
550
  tags: tags.length ? tags : undefined,
376
551
  summary,
377
552
  description,
553
+ externalDocs,
378
554
  operationId: meta?.operationId,
379
555
  parameters: parameters.length ? parameters : undefined,
380
556
  requestBody,
@@ -448,7 +624,19 @@ export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<Op
448
624
  info: {
449
625
  title: 'Galbe app',
450
626
  version: '0.1.0',
627
+ ...g.config?.openapi?.info,
451
628
  },
629
+ // basePath is a deploy location, not API structure: it is stripped from
630
+ // `paths` and surfaced through `servers` unless explicitly configured
631
+ ...(g.config?.openapi?.servers
632
+ ? { servers: g.config.openapi.servers }
633
+ : prefix
634
+ ? { servers: [{ url: prefix }] }
635
+ : {}),
636
+ // document-level blocks belong to no route: they are declared in GalbeConfig
637
+ ...(g.config?.openapi?.tags ? { tags: g.config.openapi.tags } : {}),
638
+ ...(g.config?.openapi?.security ? { security: g.config.openapi.security } : {}),
639
+ ...(g.config?.openapi?.externalDocs ? { externalDocs: g.config.openapi.externalDocs } : {}),
452
640
  paths,
453
641
  components: Object.keys(components)?.length ? components : undefined,
454
642
  }
package/src/extras.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  export { OpenAPISerializer } from './extras/spec/openapi.serializer'
2
- export type { GalbeCLICommand, GalbeCLIOptions, GalbeClientRoute, GalbeClientOptions } from './types'
2
+ export type { GalbeCLICommand, GalbeCLIOptions, GalbeClientRoute, GalbeClientOptions, OpenAPIConfig } from './types'