galbe 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -1
- package/bin/commands/build.ts +10 -4
- package/bin/commands/generate/cli/index.ts +156 -0
- package/bin/commands/generate/cli/targets/cac.ts +535 -0
- package/bin/commands/generate/client.ts +32 -109
- package/bin/commands/generate/code/openapi.parser.ts +428 -133
- package/bin/commands/generate/code/route-merge.ts +248 -0
- package/bin/commands/generate/code.ts +110 -23
- package/bin/commands/generate/index.ts +2 -0
- package/package.json +4 -1
- package/src/cookies.ts +87 -0
- package/src/extras/spec/openapi.serializer.ts +236 -101
- package/src/extras.ts +1 -0
- package/src/index.ts +4 -7
- package/src/parser.ts +104 -63
- package/src/router.ts +34 -20
- package/src/routes.ts +14 -10
- package/src/schema.ts +82 -95
- package/src/server.ts +35 -21
- package/src/types.ts +180 -61
- package/src/util.ts +10 -18
- package/src/validator.ts +62 -32
- package/bin/res/cli.template.js +0 -122
|
@@ -3,28 +3,30 @@ import type {
|
|
|
3
3
|
STIntersection,
|
|
4
4
|
STJson,
|
|
5
5
|
STLiteral,
|
|
6
|
+
STNumber,
|
|
7
|
+
STInteger,
|
|
6
8
|
STObject,
|
|
7
|
-
STProps,
|
|
8
9
|
STSchema,
|
|
10
|
+
STString,
|
|
9
11
|
STUnion,
|
|
10
12
|
} from '../../../src/schema'
|
|
11
13
|
|
|
12
14
|
import { Galbe } from '../../../src'
|
|
13
|
-
import { walkRoutes, HttpStatus
|
|
15
|
+
import { walkRoutes, HttpStatus } from '../../../src/util'
|
|
14
16
|
import { Kind, Optional } from '../../../src/schema'
|
|
15
17
|
|
|
16
18
|
import { OpenAPIV3 } from 'openapi-types'
|
|
17
19
|
|
|
18
20
|
type SchemaType = { type: string; format: string; isJson: boolean }
|
|
19
21
|
|
|
20
|
-
const schemaToMedia = ({ type, format, isJson }: SchemaType) =>
|
|
21
|
-
isJson || (type && ['object', 'number', 'boolean', 'array'].includes(type))
|
|
22
|
+
const schemaToMedia = ({ type, format, isJson }: SchemaType, hasComposite = false) =>
|
|
23
|
+
isJson || hasComposite || (type && ['object', 'number', 'boolean', 'array'].includes(type))
|
|
22
24
|
? 'application/json'
|
|
23
|
-
: format === 'byte'
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
: format === 'byte' || format === 'binary'
|
|
26
|
+
? 'application/octet-stream'
|
|
27
|
+
: type === 'string'
|
|
28
|
+
? 'text/plain'
|
|
29
|
+
: 'application/json'
|
|
28
30
|
|
|
29
31
|
export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<OpenAPIV3.Document> => {
|
|
30
32
|
let paths: any = {}
|
|
@@ -43,66 +45,53 @@ export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<Op
|
|
|
43
45
|
let kind = schema[Kind]
|
|
44
46
|
let isJson = false
|
|
45
47
|
|
|
46
|
-
let pattern = schema?.pattern?.toString()
|
|
47
|
-
if (pattern) pattern = pattern.substring(1, pattern.length - 1)
|
|
48
|
-
|
|
49
|
-
let minLength = schema?.minLength
|
|
50
|
-
let maxLength = schema?.maxLength
|
|
51
|
-
let minimum = schema?.min
|
|
52
|
-
let maximum = schema?.max
|
|
53
|
-
let exclusiveMinimum = schema?.exclusiveMin
|
|
54
|
-
let exclusiveMaximum = schema?.exclusiveMax
|
|
55
|
-
let minItems = schema?.minItems
|
|
56
|
-
let maxItems = schema?.maxItems
|
|
57
|
-
let uniqueItems = schema?.unique
|
|
58
|
-
|
|
59
48
|
if (components.schemas && (schema.id as string) in components.schemas) {
|
|
60
49
|
//@ts-ignore
|
|
61
50
|
return { schema: { $ref: `#/components/schemas/${schema.id}` } }
|
|
62
51
|
}
|
|
63
52
|
|
|
64
53
|
if (kind === 'null') {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
54
|
+
// OpenAPI 3.0 has no first-class null type; the canonical workaround
|
|
55
|
+
// is `nullable: true` with `enum: [null]` to mean "must be null".
|
|
56
|
+
s = { nullable: true, enum: [null] }
|
|
68
57
|
} else if (kind === 'boolean') s = { type: 'boolean' }
|
|
69
|
-
else if (kind === 'byteArray') s = { type: 'string', format: '
|
|
70
|
-
else if (kind === 'number')
|
|
71
|
-
|
|
72
|
-
type: 'number',
|
|
73
|
-
...(exclusiveMinimum ? { exclusiveMinimum } : {}),
|
|
74
|
-
...(exclusiveMaximum ? { exclusiveMaximum } : {}),
|
|
75
|
-
...(minimum ? { minimum } : {}),
|
|
76
|
-
...(maximum ? { maximum } : {}),
|
|
77
|
-
}
|
|
78
|
-
else if (kind === 'integer')
|
|
58
|
+
else if (kind === 'byteArray') s = { type: 'string', format: 'binary' }
|
|
59
|
+
else if (kind === 'number' || kind === 'integer') {
|
|
60
|
+
const n = schema as STNumber | STInteger
|
|
79
61
|
s = {
|
|
80
|
-
type:
|
|
81
|
-
...(
|
|
82
|
-
...(
|
|
83
|
-
...(
|
|
84
|
-
...(
|
|
62
|
+
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 } : {}),
|
|
85
67
|
}
|
|
86
|
-
else if (kind === 'string')
|
|
68
|
+
} else if (kind === 'string') {
|
|
69
|
+
const str = schema as STString
|
|
70
|
+
let pattern = str.pattern?.toString()
|
|
71
|
+
if (pattern) pattern = pattern.substring(1, pattern.length - 1)
|
|
87
72
|
s = {
|
|
88
73
|
type: 'string',
|
|
74
|
+
...(str.format ? { format: str.format } : {}),
|
|
89
75
|
...(pattern ? { pattern } : {}),
|
|
90
|
-
...(minLength ? { minLength } : {}),
|
|
91
|
-
...(maxLength ? { maxLength } : {}),
|
|
76
|
+
...(str.minLength !== undefined ? { minLength: str.minLength } : {}),
|
|
77
|
+
...(str.maxLength !== undefined ? { maxLength: str.maxLength } : {}),
|
|
92
78
|
}
|
|
93
|
-
else if (kind === 'any') s = {
|
|
79
|
+
} else if (kind === 'any') s = {}
|
|
94
80
|
else if (kind === 'literal') {
|
|
95
81
|
let value = (schema as STLiteral).value
|
|
96
82
|
s = { type: 'string', enum: [value] }
|
|
97
83
|
} else if (kind === 'array') {
|
|
84
|
+
const arr = schema as STArray
|
|
85
|
+
// ArrayOptions exposes minLength/maxLength (matching the schema-builder API);
|
|
86
|
+
// OpenAPI calls them minItems/maxItems.
|
|
98
87
|
s = {
|
|
99
88
|
type: 'array',
|
|
100
|
-
items: schemaToOpenapi(
|
|
101
|
-
...(
|
|
102
|
-
...(
|
|
103
|
-
...(
|
|
89
|
+
items: schemaToOpenapi(arr.items).schema,
|
|
90
|
+
...(arr.minLength !== undefined ? { minItems: arr.minLength } : {}),
|
|
91
|
+
...(arr.maxLength !== undefined ? { maxItems: arr.maxLength } : {}),
|
|
92
|
+
...(arr.unique ? { uniqueItems: arr.unique } : {}),
|
|
104
93
|
}
|
|
105
|
-
} else if (kind === 'object') {
|
|
94
|
+
} else if (kind === 'object' || kind === 'multipartForm') {
|
|
106
95
|
let props = (schema as STObject).props || {}
|
|
107
96
|
let required = Object.entries(props)
|
|
108
97
|
.filter(([_, v]) => !v?.[Optional])
|
|
@@ -113,41 +102,33 @@ export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<Op
|
|
|
113
102
|
...(required.length ? { required } : {}),
|
|
114
103
|
}
|
|
115
104
|
} else if (kind === 'json') {
|
|
116
|
-
|
|
117
|
-
let type = (schema as STJson).type
|
|
118
|
-
if (type === 'unknown') type = 'object'
|
|
119
|
-
let required = Object.entries(props)
|
|
120
|
-
.filter(([_, v]) => !v?.[Optional])
|
|
121
|
-
.map(([k, _]) => k)
|
|
105
|
+
const inner = (schema as STJson).value as STSchema | undefined
|
|
122
106
|
isJson = true
|
|
123
|
-
s = {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
...(required.length ? { required } : {}),
|
|
129
|
-
}
|
|
130
|
-
: {}),
|
|
131
|
-
}
|
|
132
|
-
} else if (kind === 'union') {
|
|
133
|
-
let anyOf: STSchema[] = (schema as STUnion).anyOf
|
|
134
|
-
let nullable = anyOf.some(s => s[Kind] === 'null')
|
|
135
|
-
anyOf = anyOf.filter(s => s[Kind] !== 'null')
|
|
107
|
+
s = inner ? schemaToOpenapi(inner).schema : { type: 'object' }
|
|
108
|
+
} else if (kind === 'anyOf' || kind === 'oneOf') {
|
|
109
|
+
let members: STSchema[] = (schema as STUnion).members
|
|
110
|
+
let nullable = members.some(s => s[Kind] === 'null')
|
|
111
|
+
members = members.filter(s => s[Kind] !== 'null')
|
|
136
112
|
|
|
137
|
-
|
|
113
|
+
const allStringLiterals =
|
|
114
|
+
members.length > 0 && members.every(e => e[Kind] === 'literal' && typeof (e as STLiteral).value === 'string')
|
|
115
|
+
const useOneOf = kind === 'oneOf'
|
|
116
|
+
|
|
117
|
+
if (members.length === 0) {
|
|
138
118
|
s = {}
|
|
139
|
-
} else if (
|
|
140
|
-
s = schemaToOpenapi(
|
|
141
|
-
} else if (
|
|
142
|
-
s = {
|
|
143
|
-
|
|
144
|
-
|
|
119
|
+
} else if (members.length === 1) {
|
|
120
|
+
s = schemaToOpenapi(members[0]).schema
|
|
121
|
+
} else if (allStringLiterals && !useOneOf) {
|
|
122
|
+
s = { type: 'string', enum: members.map(e => (e as STLiteral).value) }
|
|
123
|
+
} else if (members.length > 1) {
|
|
124
|
+
const variants = members.map(e => schemaToOpenapi(e).schema)
|
|
125
|
+
s = useOneOf ? { oneOf: variants } : { anyOf: variants }
|
|
145
126
|
}
|
|
146
127
|
|
|
147
128
|
//@ts-ignore
|
|
148
129
|
if (nullable) s.nullable = nullable
|
|
149
130
|
} else if (kind === 'intersection') {
|
|
150
|
-
let allOf: STSchema[] = (schema as STIntersection).allOf
|
|
131
|
+
let allOf: STSchema[] = (schema as STIntersection<any>).allOf
|
|
151
132
|
if (allOf.length === 0) {
|
|
152
133
|
s = {}
|
|
153
134
|
} else if (allOf.length === 1) {
|
|
@@ -159,7 +140,13 @@ export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<Op
|
|
|
159
140
|
}
|
|
160
141
|
}
|
|
161
142
|
|
|
162
|
-
s = {
|
|
143
|
+
s = {
|
|
144
|
+
title: schema.title,
|
|
145
|
+
description: schema.description,
|
|
146
|
+
...s,
|
|
147
|
+
...(schema?.default !== undefined ? { default: schema.default } : {}),
|
|
148
|
+
...(schema?.examples !== undefined ? { example: schema.examples } : {}),
|
|
149
|
+
}
|
|
163
150
|
if (components.schemas && schema.id) {
|
|
164
151
|
components.schemas[schema.id] = s
|
|
165
152
|
return { schema: { $ref: `#/components/schemas/${schema.id}` } }
|
|
@@ -190,7 +177,6 @@ export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<Op
|
|
|
190
177
|
deprecated: param.deprecated,
|
|
191
178
|
schema,
|
|
192
179
|
}
|
|
193
|
-
if (components.parameters && param.id) components.parameters[param.id] = p
|
|
194
180
|
return p
|
|
195
181
|
}
|
|
196
182
|
|
|
@@ -211,6 +197,25 @@ export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<Op
|
|
|
211
197
|
...(typeof meta?.tag === 'string' ? [meta?.tag] : meta?.tag || []),
|
|
212
198
|
]
|
|
213
199
|
let security: Record<string, any> = []
|
|
200
|
+
let securityExplicitlyEmpty = false
|
|
201
|
+
|
|
202
|
+
const metaSecRaw = meta?.security
|
|
203
|
+
if (metaSecRaw !== undefined) {
|
|
204
|
+
const entries = Array.isArray(metaSecRaw) ? metaSecRaw : [metaSecRaw]
|
|
205
|
+
for (const e of entries) {
|
|
206
|
+
if (typeof e !== 'string') continue
|
|
207
|
+
const trimmed = e.trim()
|
|
208
|
+
if (trimmed === 'none' || trimmed === '') {
|
|
209
|
+
securityExplicitlyEmpty = true
|
|
210
|
+
} else {
|
|
211
|
+
const [name, ...scopes] = trimmed.split(/\s+/)
|
|
212
|
+
security.push({ [name]: scopes })
|
|
213
|
+
if (name === 'bearerAuth' && components.securitySchemes && !components.securitySchemes.bearerAuth) {
|
|
214
|
+
components.securitySchemes.bearerAuth = { type: 'http', scheme: 'bearer' }
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
214
219
|
|
|
215
220
|
let pathParam = r.schema?.params
|
|
216
221
|
? Object.entries(r.schema?.params as Record<string, STSchema>).map(([k, v]) => parseParam(k, v, 'path'))
|
|
@@ -218,15 +223,21 @@ export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<Op
|
|
|
218
223
|
let queryParam = r.schema?.query
|
|
219
224
|
? Object.entries(r.schema?.query as Record<string, STSchema>).map(([k, v]) => parseParam(k, v, 'query'))
|
|
220
225
|
: []
|
|
226
|
+
const metaSecuritySet = security.length > 0
|
|
221
227
|
let headerParam = r.schema?.headers
|
|
222
228
|
? Object.entries(r.schema?.headers as Record<string, STSchema>)
|
|
223
229
|
.map(([k, v]) => {
|
|
224
230
|
let p = parseParam(k, v, 'header')
|
|
225
231
|
if (k.match(/authorization/i)) {
|
|
226
232
|
// TODO: handle other auth methods
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
230
241
|
return null
|
|
231
242
|
}
|
|
232
243
|
}
|
|
@@ -246,7 +257,7 @@ export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<Op
|
|
|
246
257
|
Object.entries(r.schema.body).map(([bodyType, schema]) => {
|
|
247
258
|
const s = schema.description
|
|
248
259
|
const isDefined = typeof s === 'string' && s !== ''
|
|
249
|
-
if (
|
|
260
|
+
if (!schema?.[Optional]) required = true
|
|
250
261
|
if (isDefined) {
|
|
251
262
|
if (description === undefined) {
|
|
252
263
|
description = s
|
|
@@ -254,8 +265,8 @@ export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<Op
|
|
|
254
265
|
conflictDescription = true
|
|
255
266
|
}
|
|
256
267
|
}
|
|
257
|
-
description = conflictDescription ? undefined : description ?? undefined
|
|
258
|
-
return [
|
|
268
|
+
description = conflictDescription ? undefined : (description ?? undefined)
|
|
269
|
+
return [bodyType, { schema: schemaToOpenapi(schema).schema }]
|
|
259
270
|
})
|
|
260
271
|
)
|
|
261
272
|
requestBody = {
|
|
@@ -270,17 +281,74 @@ export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<Op
|
|
|
270
281
|
Object.entries(r.schema.response).map(([status, v]) => {
|
|
271
282
|
if (!v) return []
|
|
272
283
|
let s = status as keyof typeof HttpStatus | 'default'
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
let
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
284
|
+
const isContentMap = !(v as any)[Kind]
|
|
285
|
+
const explicitHeaders = (v as any)?.responseHeaders as Record<string, STSchema> | undefined
|
|
286
|
+
let response: OpenAPIV3.ResponseObject
|
|
287
|
+
|
|
288
|
+
if (isContentMap) {
|
|
289
|
+
// STResponseContent — iterate body-type keys
|
|
290
|
+
const cm = v as any
|
|
291
|
+
const desc = cm.description || HttpStatus[s as keyof typeof HttpStatus] || 'Response'
|
|
292
|
+
const content: Record<string, { schema: any; example?: any; examples?: Record<string, any> }> = {}
|
|
293
|
+
for (const [key, bodySchema] of Object.entries(cm)) {
|
|
294
|
+
if (key === 'description' || key === 'responseHeaders' || key === 'example' || key === 'examples') continue
|
|
295
|
+
const { schema: oaSchema } = schemaToOpenapi(bodySchema as STSchema)
|
|
296
|
+
content[key] = { schema: oaSchema }
|
|
297
|
+
const ex = (bodySchema as any)?.examples
|
|
298
|
+
const exSingle = (bodySchema as any)?.example
|
|
299
|
+
if (ex && Object.keys(ex).length) content[key].examples = ex
|
|
300
|
+
if (exSingle !== undefined) content[key].example = exSingle
|
|
301
|
+
}
|
|
302
|
+
response = { description: desc, ...(Object.keys(content).length ? { content } : {}) }
|
|
303
|
+
} 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'
|
|
307
|
+
if (noContent) {
|
|
308
|
+
response = {
|
|
309
|
+
description: (v as any).description || HttpStatus[s as keyof typeof HttpStatus] || 'Response',
|
|
310
|
+
}
|
|
311
|
+
} else {
|
|
312
|
+
let { schema, isJson } = schemaToOpenapi(v as STSchema)
|
|
313
|
+
let resolved = resolveRef(schema)
|
|
314
|
+
let { type, format } = resolved
|
|
315
|
+
let hasComposite = !!(resolved as any)?.allOf || !!(resolved as any)?.anyOf || !!(resolved as any)?.oneOf
|
|
316
|
+
const mediaType = schemaToMedia({ type, format, isJson } as SchemaType, hasComposite)
|
|
317
|
+
const explicitExamples = (v as any)?.examples as Record<string, any> | undefined
|
|
318
|
+
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
|
|
323
|
+
if (explicitExample !== undefined) content[mediaType].example = explicitExample
|
|
324
|
+
response = {
|
|
325
|
+
description: (v as any).description || HttpStatus[s as keyof typeof HttpStatus] || 'Response',
|
|
326
|
+
content,
|
|
327
|
+
}
|
|
328
|
+
}
|
|
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
|
+
}
|
|
279
336
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
337
|
+
|
|
338
|
+
if (explicitHeaders && Object.keys(explicitHeaders).length) {
|
|
339
|
+
response.headers = {}
|
|
340
|
+
for (const [hName, hSchema] of Object.entries(explicitHeaders)) {
|
|
341
|
+
const isRequired = !(hSchema as any)?.[Optional]
|
|
342
|
+
const stripped = { ...(hSchema as any), [Optional]: false } as STSchema
|
|
343
|
+
const { schema: hSer } = schemaToOpenapi(stripped)
|
|
344
|
+
const headerObj: OpenAPIV3.HeaderObject = {
|
|
345
|
+
...((hSchema as any)?.description ? { description: (hSchema as any).description } : {}),
|
|
346
|
+
...(isRequired ? { required: true } : {}),
|
|
347
|
+
schema: hSer,
|
|
348
|
+
}
|
|
349
|
+
if ((hSer as any)?.description) delete (hSer as any).description
|
|
350
|
+
response.headers[hName] = headerObj
|
|
351
|
+
}
|
|
284
352
|
}
|
|
285
353
|
return [s, response]
|
|
286
354
|
})
|
|
@@ -290,24 +358,91 @@ export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<Op
|
|
|
290
358
|
default: { description: HttpStatus[200] },
|
|
291
359
|
}
|
|
292
360
|
}
|
|
293
|
-
|
|
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
|
+
}
|
|
373
|
+
}
|
|
294
374
|
paths[path][r.method] = {
|
|
295
375
|
tags: tags.length ? tags : undefined,
|
|
296
|
-
summary
|
|
376
|
+
summary,
|
|
377
|
+
description,
|
|
297
378
|
operationId: meta?.operationId,
|
|
298
379
|
parameters: parameters.length ? parameters : undefined,
|
|
299
380
|
requestBody,
|
|
300
381
|
responses,
|
|
301
|
-
...(security.length ? { security } : {}),
|
|
382
|
+
...(security.length ? { security } : securityExplicitlyEmpty ? { security: [] } : {}),
|
|
302
383
|
deprecated: meta?.deprecated ? true : undefined,
|
|
303
384
|
}
|
|
304
385
|
})
|
|
305
386
|
|
|
387
|
+
// Promote parameters that appear with the exact same shape on more than
|
|
388
|
+
// one operation into components.parameters and replace each occurrence
|
|
389
|
+
// with a $ref. Naming: the parameter's `name`, capitalised; collisions
|
|
390
|
+
// with different shapes get suffixed.
|
|
391
|
+
const stableStringify = (v: any): string => {
|
|
392
|
+
if (v === null || typeof v !== 'object') return JSON.stringify(v)
|
|
393
|
+
if (Array.isArray(v)) return `[${v.map(stableStringify).join(',')}]`
|
|
394
|
+
const keys = Object.keys(v).sort()
|
|
395
|
+
return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(v[k])}`).join(',')}}`
|
|
396
|
+
}
|
|
397
|
+
const paramHashes = new Map<string, { count: number; param: any }>()
|
|
398
|
+
for (const path of Object.values(paths) as any[]) {
|
|
399
|
+
for (const m of Object.keys(path)) {
|
|
400
|
+
if (m === 'parameters') continue
|
|
401
|
+
for (const p of path[m]?.parameters || []) {
|
|
402
|
+
if (p.$ref) continue
|
|
403
|
+
const key = stableStringify(p)
|
|
404
|
+
const entry = paramHashes.get(key)
|
|
405
|
+
if (entry) entry.count++
|
|
406
|
+
else paramHashes.set(key, { count: 1, param: p })
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const cap = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
|
|
411
|
+
const promoted = new Map<string, string>() // hash -> component name
|
|
412
|
+
const usedNames = new Set<string>(Object.keys(components.parameters || {}))
|
|
413
|
+
for (const [hash, { count, param }] of paramHashes) {
|
|
414
|
+
if (count < 2) continue
|
|
415
|
+
let base = cap(String(param.name || 'Param')).replace(/[^A-Za-z0-9]/g, '')
|
|
416
|
+
let name = base
|
|
417
|
+
let i = 2
|
|
418
|
+
while (usedNames.has(name)) name = `${base}${i++}`
|
|
419
|
+
usedNames.add(name)
|
|
420
|
+
promoted.set(hash, name)
|
|
421
|
+
components.parameters![name] = param
|
|
422
|
+
}
|
|
423
|
+
if (promoted.size) {
|
|
424
|
+
for (const path of Object.values(paths) as any[]) {
|
|
425
|
+
for (const m of Object.keys(path)) {
|
|
426
|
+
if (m === 'parameters') continue
|
|
427
|
+
const op = path[m]
|
|
428
|
+
if (!op?.parameters) continue
|
|
429
|
+
op.parameters = op.parameters.map((p: any) => {
|
|
430
|
+
if (p.$ref) return p
|
|
431
|
+
const name = promoted.get(stableStringify(p))
|
|
432
|
+
return name ? { $ref: `#/components/parameters/${name}` } : p
|
|
433
|
+
})
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
306
438
|
//@ts-ignore
|
|
307
|
-
components = Object.entries(components).reduce(
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
439
|
+
components = Object.entries(components).reduce(
|
|
440
|
+
(p, [k, v]) => {
|
|
441
|
+
if (Object.keys(v).length) p[k] = v
|
|
442
|
+
return p
|
|
443
|
+
},
|
|
444
|
+
{} as Record<string, OpenAPIV3.ComponentsObject>
|
|
445
|
+
)
|
|
311
446
|
return {
|
|
312
447
|
openapi: version,
|
|
313
448
|
info: {
|
package/src/extras.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
Handler,
|
|
9
9
|
Endpoint,
|
|
10
10
|
Context,
|
|
11
|
+
ContextSet,
|
|
11
12
|
ErrorHandler,
|
|
12
13
|
GalbePlugin,
|
|
13
14
|
STBody,
|
|
@@ -86,14 +87,9 @@ const galbeMethod = <
|
|
|
86
87
|
query: {} as Static<STObject<Exclude<(typeof schema)['query'], undefined>>>,
|
|
87
88
|
body: ['get', 'options', 'head'].includes(method) ? null : ({} as unknown as STBodyValue),
|
|
88
89
|
request: {} as Request,
|
|
90
|
+
cookies: {} as Record<string, string>,
|
|
89
91
|
state: {},
|
|
90
|
-
set: {} as
|
|
91
|
-
headers: {
|
|
92
|
-
'set-cookie': string[]
|
|
93
|
-
[header: string]: string | string[]
|
|
94
|
-
}
|
|
95
|
-
status?: number
|
|
96
|
-
},
|
|
92
|
+
set: {} as ContextSet,
|
|
97
93
|
}
|
|
98
94
|
return {
|
|
99
95
|
method,
|
|
@@ -109,6 +105,7 @@ const galbeMethod = <
|
|
|
109
105
|
export const $T = new SchemaType()
|
|
110
106
|
|
|
111
107
|
export { RequestError } from './types'
|
|
108
|
+
export type { STResponseContent, STResponseBodyKey, STResponseEntry } from './types'
|
|
112
109
|
|
|
113
110
|
export const config = (config: GalbeConfig) => config
|
|
114
111
|
|