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.
- 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 +378 -73
- 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 +368 -92
- 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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { semver } from 'bun'
|
|
2
|
-
import { transformSync } from '@swc/
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
232
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
344
|
+
// a bodiless component response: no media types, description only
|
|
345
|
+
responseContent = []
|
|
262
346
|
} else {
|
|
263
|
-
const contentMap = s.content as Record<
|
|
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)
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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>> = {
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
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))
|
|
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 =
|
|
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 =
|
|
518
|
-
|
|
519
|
-
//
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
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
|
|
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(
|
|
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
|
-
//
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
if (type === 'responses') {
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
)
|
|
654
|
-
|
|
655
|
-
|
|
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:
|
|
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/
|
|
945
|
+
/** e.g. '/main', '/v1/modules'. */
|
|
681
946
|
scopeKey: string
|
|
682
|
-
/**
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 (
|