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.
- package/README.md +3 -0
- package/bin/commands/build.ts +30 -19
- package/bin/commands/dev.ts +53 -5
- package/bin/commands/generate/cli/index.ts +4 -1
- package/bin/commands/generate/client.ts +61 -30
- package/bin/commands/generate/code/openapi.parser.ts +440 -163
- package/bin/commands/generate/code/route-merge.ts +26 -21
- package/bin/commands/generate/code.ts +15 -1
- package/bin/commands/generate/model.ts +4 -1
- package/bin/commands/generate/spec.ts +3 -1
- package/bin/res/client.runtime.ts +5 -0
- package/bin/util.ts +36 -90
- package/package.json +34 -9
- package/src/cookies.ts +29 -8
- package/src/extras/spec/openapi.serializer.ts +287 -99
- package/src/extras.ts +1 -1
- package/src/index.ts +377 -71
- package/src/middlewares/_auth.ts +178 -0
- package/src/middlewares/apiKey.ts +139 -0
- package/src/middlewares/basicAuth.ts +151 -0
- package/src/middlewares/bearer.ts +136 -0
- package/src/middlewares/jwt.ts +455 -0
- package/src/middlewares/logger.ts +120 -0
- package/src/middlewares/rateLimit.ts +153 -0
- package/src/middlewares/requestId.ts +94 -0
- package/src/middlewares/timing.ts +86 -0
- package/src/middlewares.ts +53 -0
- package/src/parser.ts +279 -133
- package/src/router.ts +74 -51
- package/src/routes.ts +220 -136
- package/src/schema.ts +123 -31
- package/src/server.ts +130 -70
- package/src/types.ts +366 -90
- package/src/util.ts +271 -5
- package/src/validator.compile.ts +343 -0
- package/src/validator.ts +64 -18
- package/bin/res/client.template.ts +0 -200
- package/scripts/release.ts +0 -196
|
@@ -12,10 +12,17 @@ import type {
|
|
|
12
12
|
} from '../../../src/schema'
|
|
13
13
|
|
|
14
14
|
import { Galbe } from '../../../src'
|
|
15
|
-
import {
|
|
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.
|
|
64
|
-
...(
|
|
65
|
-
...(
|
|
66
|
-
...(n.
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
122
|
-
s =
|
|
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 =
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
...(
|
|
197
|
-
...(typeof
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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(
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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 —
|
|
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 ||
|
|
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
|
|
458
|
+
if (!key.includes('/') || !bodySchema) continue
|
|
295
459
|
const { schema: oaSchema } = schemaToOpenapi(bodySchema as STSchema)
|
|
296
460
|
content[key] = { schema: oaSchema }
|
|
297
|
-
|
|
298
|
-
const
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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 ||
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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 ||
|
|
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
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
}
|
|
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'
|