@lovrozagar/honey 0.5.2 → 0.6.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 +11 -9
- package/dist/codegen.d.ts +4 -21
- package/dist/codegen.d.ts.map +1 -1
- package/dist/openapi/collect.d.ts +17 -0
- package/dist/openapi/collect.d.ts.map +1 -0
- package/dist/openapi/document.d.ts +29 -0
- package/dist/openapi/document.d.ts.map +1 -0
- package/dist/openapi/json-schema-slot.d.ts +5 -0
- package/dist/openapi/json-schema-slot.d.ts.map +1 -0
- package/dist/openapi/spec-factory.d.ts +1 -0
- package/dist/openapi/spec-factory.d.ts.map +1 -1
- package/dist/openapi/spec.d.ts +1 -1
- package/dist/openapi/spec.d.ts.map +1 -1
- package/dist/proxy.d.ts +3 -3
- package/dist/proxy.d.ts.map +1 -1
- package/dist/tree.d.ts +5 -5
- package/dist/tree.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/codegen.ts +19 -615
- package/src/index.ts +7 -7
- package/src/openapi/collect.ts +75 -0
- package/src/openapi/document.ts +614 -0
- package/src/openapi/json-schema-slot.ts +13 -0
- package/src/openapi/spec-factory.ts +4 -0
- package/src/openapi/spec.ts +21 -11
- package/src/proxy.ts +6 -5
- package/src/tree.ts +5 -5
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
import { HoneyError } from "../error.ts"
|
|
2
|
+
import { ERROR_META } from "../errors.ts"
|
|
3
|
+
import type { ErrorMetaEntry } from "../errors.ts"
|
|
4
|
+
import type { InvalidateCheckConfig, InvalidateCheckOperation } from "../invalidate-check.ts"
|
|
5
|
+
import { reportMissingInvalidate } from "../invalidate-check.ts"
|
|
6
|
+
import {
|
|
7
|
+
applyMetaSpec,
|
|
8
|
+
compileMetaSpec,
|
|
9
|
+
MetaSpecCollector,
|
|
10
|
+
resolveProfile,
|
|
11
|
+
type SchemaMetaHit,
|
|
12
|
+
type SchemaMetaLookup,
|
|
13
|
+
} from "../meta-spec.ts"
|
|
14
|
+
import type { RouteHandler, TreeNode } from "../tree.ts"
|
|
15
|
+
import type {
|
|
16
|
+
InputSchemaEntry,
|
|
17
|
+
InputSchemasDef,
|
|
18
|
+
MetaSpecConfig,
|
|
19
|
+
MetaSpecSchemaSource,
|
|
20
|
+
StandardSchemaLike,
|
|
21
|
+
} from "../types.ts"
|
|
22
|
+
import { statusKeyToCode } from "../types.ts"
|
|
23
|
+
import { type CollectedWSRoute, extractParams, toOpenApiPath, unwrapEntry, walkTree, walkWSRoutes } from "./collect.ts"
|
|
24
|
+
import { getJsonSchemaConverter } from "./json-schema-slot.ts"
|
|
25
|
+
|
|
26
|
+
export type OpenApiRouteInfo<TMeta = unknown> = {
|
|
27
|
+
meta: TMeta
|
|
28
|
+
method: string
|
|
29
|
+
path: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type OpenApiInfo = {
|
|
33
|
+
description?: string
|
|
34
|
+
title: string
|
|
35
|
+
version: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type OpenApiSpec = {
|
|
39
|
+
components?: {
|
|
40
|
+
schemas?: Record<string, Record<string, unknown>>
|
|
41
|
+
securitySchemes?: Record<string, unknown>
|
|
42
|
+
}
|
|
43
|
+
info: OpenApiInfo
|
|
44
|
+
openapi: string
|
|
45
|
+
paths: Record<string, Record<string, Record<string, unknown>>>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const DEFAULT_ERROR_JSON_SCHEMA: Record<string, unknown> = {
|
|
49
|
+
properties: {
|
|
50
|
+
error_key: { type: "string" },
|
|
51
|
+
fields: {
|
|
52
|
+
additionalProperties: {
|
|
53
|
+
items: {
|
|
54
|
+
properties: {
|
|
55
|
+
error_key: { type: "string" },
|
|
56
|
+
message: { type: "string" },
|
|
57
|
+
path: { type: "string" },
|
|
58
|
+
},
|
|
59
|
+
required: ["error_key", "message", "path"],
|
|
60
|
+
type: "object",
|
|
61
|
+
},
|
|
62
|
+
type: "array",
|
|
63
|
+
},
|
|
64
|
+
type: "object",
|
|
65
|
+
},
|
|
66
|
+
message: { type: "string" },
|
|
67
|
+
status: { type: "integer" },
|
|
68
|
+
status_key: { type: "string" },
|
|
69
|
+
success: { const: false },
|
|
70
|
+
},
|
|
71
|
+
required: ["error_key", "fields", "message", "status", "status_key", "success"],
|
|
72
|
+
type: "object",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type ErrorInfo = { errorKey: string; status: number; statusKey: string }
|
|
76
|
+
|
|
77
|
+
const STATUS_KEYS_BY_LENGTH = (Object.keys(statusKeyToCode) as Array<keyof typeof statusKeyToCode>).sort(
|
|
78
|
+
(a, b) => b.length - a.length,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
function inferErrorInfo(errorKey: string): ErrorInfo | null {
|
|
82
|
+
const exact = statusKeyToCode[errorKey as keyof typeof statusKeyToCode]
|
|
83
|
+
if (exact !== undefined) {
|
|
84
|
+
if (exact < 400) return null
|
|
85
|
+
return { errorKey, status: exact, statusKey: errorKey }
|
|
86
|
+
}
|
|
87
|
+
for (const statusKey of STATUS_KEYS_BY_LENGTH) {
|
|
88
|
+
if (!errorKey.endsWith(`_${statusKey}`)) continue
|
|
89
|
+
const status = statusKeyToCode[statusKey]
|
|
90
|
+
if (status < 400) continue
|
|
91
|
+
return { errorKey, status, statusKey }
|
|
92
|
+
}
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function resolveErrorInfo(errorKey: string, factory: Record<string, () => HoneyError> | null): ErrorInfo {
|
|
97
|
+
if (factory?.[errorKey]) {
|
|
98
|
+
try {
|
|
99
|
+
const err = factory[errorKey]()
|
|
100
|
+
return { errorKey, status: err.status, statusKey: err.statusKey }
|
|
101
|
+
} catch {
|
|
102
|
+
/* factory call failed — fall through to suffix inference */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return inferErrorInfo(errorKey) ?? { errorKey, status: 0, statusKey: "unknown" }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function getErrorFactory(app: unknown): Record<string, () => HoneyError> | null {
|
|
109
|
+
return (app as { _errorFactory: Record<string, () => HoneyError> | null })._errorFactory
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getErrorSchema(app: unknown): StandardSchemaLike | null {
|
|
113
|
+
return (app as { _errorSchema: StandardSchemaLike | null })._errorSchema
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function getCustomErrorSchema(app: unknown): StandardSchemaLike | null {
|
|
117
|
+
return (app as { _customErrorSchema: StandardSchemaLike | null })._customErrorSchema
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function getErrorMeta(factory: Record<string, () => HoneyError> | null): Record<string, ErrorMetaEntry> | null {
|
|
121
|
+
if (!factory) return null
|
|
122
|
+
return (factory as Record<symbol, Record<string, ErrorMetaEntry>>)[ERROR_META] ?? null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function getMetaSpecConfig(app: unknown): MetaSpecConfig | null {
|
|
126
|
+
return (app as { _metaSpec?: MetaSpecConfig | null })._metaSpec ?? null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function cloneJson<T>(value: T): T {
|
|
130
|
+
try {
|
|
131
|
+
return structuredClone(value)
|
|
132
|
+
} catch {
|
|
133
|
+
return JSON.parse(JSON.stringify(value)) as T
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function stripInternalProps(schema: Record<string, unknown>): Record<string, unknown> {
|
|
138
|
+
const props = schema.properties as Record<string, Record<string, unknown>> | undefined
|
|
139
|
+
if (!props) return schema
|
|
140
|
+
const filtered: Record<string, unknown> = {}
|
|
141
|
+
for (const [key, val] of Object.entries(props)) {
|
|
142
|
+
if (val?.["x-internal"] === true) continue
|
|
143
|
+
filtered[key] = val
|
|
144
|
+
}
|
|
145
|
+
const required = schema.required as string[] | undefined
|
|
146
|
+
const result: Record<string, unknown> = { ...schema, properties: filtered }
|
|
147
|
+
if (required) {
|
|
148
|
+
result.required = required.filter((k) => filtered[k] !== undefined)
|
|
149
|
+
}
|
|
150
|
+
return result
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const BINARY_FORM_FORMATS = new Set(["binary", "byte", "file"])
|
|
154
|
+
|
|
155
|
+
function isBinaryFormPart(schema: Record<string, unknown> | undefined): boolean {
|
|
156
|
+
if (!schema) return false
|
|
157
|
+
const format = schema.format
|
|
158
|
+
if (typeof format === "string" && BINARY_FORM_FORMATS.has(format)) return true
|
|
159
|
+
if (schema.type === "file") return true
|
|
160
|
+
if (schema.type === "array") {
|
|
161
|
+
const items = schema.items
|
|
162
|
+
if (items !== null && typeof items === "object" && !Array.isArray(items)) {
|
|
163
|
+
return isBinaryFormPart(items as Record<string, unknown>)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return false
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function formSchemaHasBinaryPart(schema: Record<string, unknown>): boolean {
|
|
170
|
+
const props = schema.properties as Record<string, Record<string, unknown>> | undefined
|
|
171
|
+
if (!props) return false
|
|
172
|
+
for (const prop of Object.values(props)) {
|
|
173
|
+
if (isBinaryFormPart(prop)) return true
|
|
174
|
+
}
|
|
175
|
+
return false
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function asJsonSchema(
|
|
179
|
+
entry: StandardSchemaLike | Record<string, unknown>,
|
|
180
|
+
io: "input" | "output" = "output",
|
|
181
|
+
): Record<string, unknown> {
|
|
182
|
+
if (entry !== null && entry !== undefined && "~standard" in (entry as object)) {
|
|
183
|
+
const convert = getJsonSchemaConverter()
|
|
184
|
+
if (convert) return convert(entry as StandardSchemaLike, io)
|
|
185
|
+
return {}
|
|
186
|
+
}
|
|
187
|
+
return entry as Record<string, unknown>
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function primaryOutputSchema(handler: RouteHandler): StandardSchemaLike | null {
|
|
191
|
+
const json = handler.os?.["application/json"] as Record<string, unknown> | undefined
|
|
192
|
+
if (!json) return null
|
|
193
|
+
let best: { code: number; schema: StandardSchemaLike } | null = null
|
|
194
|
+
for (const [statusKey, schema] of Object.entries(json)) {
|
|
195
|
+
if (schema === undefined) continue
|
|
196
|
+
const code = statusKeyToCode[statusKey as keyof typeof statusKeyToCode]
|
|
197
|
+
if (!code || code < 200 || code > 299) continue
|
|
198
|
+
if (!best || code < best.code) best = { code, schema: schema as StandardSchemaLike }
|
|
199
|
+
}
|
|
200
|
+
return best?.schema ?? null
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function schemaChildren(node: Record<string, unknown>): Record<string, unknown>[] {
|
|
204
|
+
const out: Record<string, unknown>[] = []
|
|
205
|
+
const push = (value: unknown): void => {
|
|
206
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
207
|
+
out.push(value as Record<string, unknown>)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const props = node.properties
|
|
211
|
+
if (props !== null && typeof props === "object" && !Array.isArray(props)) {
|
|
212
|
+
for (const child of Object.values(props as Record<string, unknown>)) push(child)
|
|
213
|
+
}
|
|
214
|
+
push(node.items)
|
|
215
|
+
for (const key of ["allOf", "anyOf", "oneOf", "prefixItems"]) {
|
|
216
|
+
const members = node[key]
|
|
217
|
+
if (Array.isArray(members)) {
|
|
218
|
+
for (const member of members) push(member)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return out
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function searchSchemaKey(root: Record<string, unknown>, key: string, maxDepth: number): SchemaMetaHit {
|
|
225
|
+
let level = [root]
|
|
226
|
+
for (let depth = 0; depth <= maxDepth && level.length > 0; depth++) {
|
|
227
|
+
const matches: unknown[] = []
|
|
228
|
+
const seen = new Set<string>()
|
|
229
|
+
for (const node of level) {
|
|
230
|
+
if (!Object.hasOwn(node, key) || node[key] === undefined) continue
|
|
231
|
+
const fingerprint = JSON.stringify(node[key]) ?? "undefined"
|
|
232
|
+
if (seen.has(fingerprint)) continue
|
|
233
|
+
seen.add(fingerprint)
|
|
234
|
+
matches.push(node[key])
|
|
235
|
+
}
|
|
236
|
+
if (matches.length === 1) return { found: true, value: matches[0] }
|
|
237
|
+
if (matches.length > 1) return { found: "ambiguous", values: matches }
|
|
238
|
+
const next: Record<string, unknown>[] = []
|
|
239
|
+
for (const node of level) next.push(...schemaChildren(node))
|
|
240
|
+
level = next
|
|
241
|
+
}
|
|
242
|
+
return { found: false }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const DEEP_SEARCH_MAX_DEPTH = 6
|
|
246
|
+
|
|
247
|
+
function makeSchemaMetaLookup(handler: RouteHandler): SchemaMetaLookup {
|
|
248
|
+
const cache = new Map<string, Record<string, unknown> | undefined>()
|
|
249
|
+
|
|
250
|
+
const rootOf = (source: MetaSpecSchemaSource): Record<string, unknown> | undefined => {
|
|
251
|
+
if (cache.has(source)) return cache.get(source)
|
|
252
|
+
let schema: StandardSchemaLike | null = null
|
|
253
|
+
let io: "input" | "output" = "output"
|
|
254
|
+
if (source === "output") {
|
|
255
|
+
schema = primaryOutputSchema(handler)
|
|
256
|
+
} else {
|
|
257
|
+
io = "input"
|
|
258
|
+
const key = source.slice("input.".length) as keyof InputSchemasDef
|
|
259
|
+
const entry = handler.iv?.[key]
|
|
260
|
+
schema = entry ? unwrapEntry(entry as InputSchemaEntry) : null
|
|
261
|
+
}
|
|
262
|
+
let root: Record<string, unknown> | undefined
|
|
263
|
+
if (schema) {
|
|
264
|
+
const converted = asJsonSchema(schema, io)
|
|
265
|
+
root = converted && typeof converted === "object" ? converted : undefined
|
|
266
|
+
}
|
|
267
|
+
cache.set(source, root)
|
|
268
|
+
return root
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return (source, key, search) => {
|
|
272
|
+
const root = rootOf(source)
|
|
273
|
+
if (!root) return { found: false }
|
|
274
|
+
if (search === "deep") return searchSchemaKey(root, key, DEEP_SEARCH_MAX_DEPTH)
|
|
275
|
+
if (Object.hasOwn(root, key) && root[key] !== undefined) return { found: true, value: root[key] }
|
|
276
|
+
if (root.type === "array") {
|
|
277
|
+
const items = root.items
|
|
278
|
+
if (items !== null && typeof items === "object" && !Array.isArray(items)) {
|
|
279
|
+
const itemRoot = items as Record<string, unknown>
|
|
280
|
+
if (Object.hasOwn(itemRoot, key) && itemRoot[key] !== undefined) {
|
|
281
|
+
return { found: true, value: itemRoot[key] }
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return { found: false }
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function generateOpenApiFromTree<TMeta = unknown>(
|
|
290
|
+
app: unknown,
|
|
291
|
+
options: {
|
|
292
|
+
filterRoutes?: (route: OpenApiRouteInfo<TMeta>) => boolean
|
|
293
|
+
info: OpenApiInfo
|
|
294
|
+
invalidate?: InvalidateCheckConfig
|
|
295
|
+
profile?: string
|
|
296
|
+
securitySchemes?: Record<string, unknown>
|
|
297
|
+
},
|
|
298
|
+
): OpenApiSpec {
|
|
299
|
+
const collector = new MetaSpecCollector()
|
|
300
|
+
const metaSpec = compileMetaSpec(getMetaSpecConfig(app), collector)
|
|
301
|
+
const { filter: profileFilter, name: profileName } = resolveProfile(metaSpec, options.profile, collector)
|
|
302
|
+
const factory = getErrorFactory(app)
|
|
303
|
+
const errorMeta = getErrorMeta(factory)
|
|
304
|
+
|
|
305
|
+
const rawErrorSchema = getErrorSchema(app)
|
|
306
|
+
const baseErrorJsonSchema = rawErrorSchema ? asJsonSchema(rawErrorSchema) : DEFAULT_ERROR_JSON_SCHEMA
|
|
307
|
+
|
|
308
|
+
const rawCustomErrorSchema = getCustomErrorSchema(app)
|
|
309
|
+
const customErrorAddsSchema = rawCustomErrorSchema ? asJsonSchema(rawCustomErrorSchema) : null
|
|
310
|
+
|
|
311
|
+
const tree = (app as { _tree: TreeNode })._tree
|
|
312
|
+
const collected: Array<{ handler: RouteHandler; method: string; path: string }> = []
|
|
313
|
+
walkTree(tree, "", collected)
|
|
314
|
+
|
|
315
|
+
const routeFilter = options.filterRoutes
|
|
316
|
+
const paths: Record<string, Record<string, Record<string, unknown>>> = {}
|
|
317
|
+
const emitted: InvalidateCheckOperation[] = []
|
|
318
|
+
|
|
319
|
+
for (const { handler, method, path } of collected) {
|
|
320
|
+
if (routeFilter) {
|
|
321
|
+
const meta = handler.mt as TMeta
|
|
322
|
+
if (!routeFilter({ meta, method, path })) continue
|
|
323
|
+
}
|
|
324
|
+
const oaPath = toOpenApiPath(path)
|
|
325
|
+
if (paths[oaPath] === undefined) {
|
|
326
|
+
paths[oaPath] = {}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const methodKey = method.toLowerCase()
|
|
330
|
+
const operation: Record<string, unknown> = {}
|
|
331
|
+
const responses: Record<string, unknown> = {}
|
|
332
|
+
const parameters: Array<Record<string, unknown>> = []
|
|
333
|
+
|
|
334
|
+
applyMetaSpec({
|
|
335
|
+
collector,
|
|
336
|
+
filter: profileFilter,
|
|
337
|
+
kind: "http",
|
|
338
|
+
meta: handler.mt as Record<string, unknown> | null,
|
|
339
|
+
method,
|
|
340
|
+
operation,
|
|
341
|
+
path,
|
|
342
|
+
profile: profileName,
|
|
343
|
+
schemaMeta: metaSpec.needsSchemas ? makeSchemaMetaLookup(handler) : undefined,
|
|
344
|
+
spec: metaSpec,
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
const params = extractParams(path)
|
|
348
|
+
for (const name of params) {
|
|
349
|
+
const isOptional = path.includes(`:${name}?`)
|
|
350
|
+
parameters.push({
|
|
351
|
+
in: "path",
|
|
352
|
+
name,
|
|
353
|
+
required: !isOptional,
|
|
354
|
+
schema: { type: "string" },
|
|
355
|
+
})
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (handler.iv) {
|
|
359
|
+
for (const [source, entry] of Object.entries(handler.iv)) {
|
|
360
|
+
if (entry === undefined) continue
|
|
361
|
+
const unwrapped = unwrapEntry(entry as InputSchemaEntry)
|
|
362
|
+
const jsonSchema = asJsonSchema(unwrapped, "input")
|
|
363
|
+
|
|
364
|
+
if (source === "json" || source === "form") {
|
|
365
|
+
const schema = stripInternalProps(jsonSchema)
|
|
366
|
+
const content: Record<string, { schema: Record<string, unknown> }> = {}
|
|
367
|
+
if (source === "form") {
|
|
368
|
+
if (formSchemaHasBinaryPart(schema)) {
|
|
369
|
+
content["multipart/form-data"] = { schema }
|
|
370
|
+
}
|
|
371
|
+
content["application/x-www-form-urlencoded"] = { schema }
|
|
372
|
+
} else {
|
|
373
|
+
content["application/json"] = { schema }
|
|
374
|
+
}
|
|
375
|
+
operation.requestBody = {
|
|
376
|
+
content,
|
|
377
|
+
required: true,
|
|
378
|
+
}
|
|
379
|
+
} else if (source === "search" || source === "headers" || source === "cookies") {
|
|
380
|
+
let location: "cookie" | "header" | "query" = "cookie"
|
|
381
|
+
if (source === "search") location = "query"
|
|
382
|
+
else if (source === "headers") location = "header"
|
|
383
|
+
const props = jsonSchema.properties as Record<string, unknown> | undefined
|
|
384
|
+
const required = (jsonSchema.required as string[]) ?? []
|
|
385
|
+
if (props) {
|
|
386
|
+
for (const [propName, propSchema] of Object.entries(props)) {
|
|
387
|
+
if ((propSchema as Record<string, unknown>)?.["x-internal"] === true) continue
|
|
388
|
+
parameters.push({
|
|
389
|
+
in: location,
|
|
390
|
+
name: propName,
|
|
391
|
+
required: required.includes(propName),
|
|
392
|
+
schema: propSchema,
|
|
393
|
+
})
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
} else if (source === "params") {
|
|
397
|
+
const props = jsonSchema.properties as Record<string, unknown> | undefined
|
|
398
|
+
if (props) {
|
|
399
|
+
for (const param of parameters) {
|
|
400
|
+
const propSchema = props[param.name as string]
|
|
401
|
+
if (propSchema && param.in === "path") {
|
|
402
|
+
param.schema = propSchema
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (parameters.length > 0) {
|
|
411
|
+
operation.parameters = parameters
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (handler.os) {
|
|
415
|
+
for (const [contentType, schemas] of Object.entries(handler.os)) {
|
|
416
|
+
if (schemas === undefined) continue
|
|
417
|
+
if (contentType === "redirect") {
|
|
418
|
+
for (const statusKey of Object.keys(schemas)) {
|
|
419
|
+
const statusCode = statusKeyToCode[statusKey as keyof typeof statusKeyToCode]
|
|
420
|
+
if (statusCode) {
|
|
421
|
+
responses[String(statusCode)] = {
|
|
422
|
+
description: statusKey.replace(/_/g, " "),
|
|
423
|
+
headers: {
|
|
424
|
+
Location: {
|
|
425
|
+
description: "Redirect target URL",
|
|
426
|
+
schema: { format: "uri", type: "string" },
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
continue
|
|
433
|
+
}
|
|
434
|
+
for (const [statusKey, schema] of Object.entries(schemas)) {
|
|
435
|
+
if (schema === undefined) continue
|
|
436
|
+
const statusCode = statusKeyToCode[statusKey as keyof typeof statusKeyToCode]
|
|
437
|
+
if (statusCode) {
|
|
438
|
+
responses[String(statusCode)] = {
|
|
439
|
+
content: {
|
|
440
|
+
[contentType]: {
|
|
441
|
+
schema: asJsonSchema(schema as StandardSchemaLike | Record<string, unknown>),
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
description: statusKey.replace(/_/g, " "),
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (handler.ek.size > 0) {
|
|
452
|
+
type ErrorEntry = { key: string; schema: Record<string, unknown> | null }
|
|
453
|
+
const byStatus = new Map<number, ErrorEntry[]>()
|
|
454
|
+
for (const ek of handler.ek) {
|
|
455
|
+
const info = resolveErrorInfo(ek, factory)
|
|
456
|
+
if (info.status > 0) {
|
|
457
|
+
let entries = byStatus.get(info.status)
|
|
458
|
+
if (!entries) {
|
|
459
|
+
entries = []
|
|
460
|
+
byStatus.set(info.status, entries)
|
|
461
|
+
}
|
|
462
|
+
const meta = errorMeta?.[ek]
|
|
463
|
+
let customSchema: Record<string, unknown> | null = null
|
|
464
|
+
if (meta?.schema) {
|
|
465
|
+
const converted = asJsonSchema(meta.schema as StandardSchemaLike)
|
|
466
|
+
if (converted && Object.keys(converted).length > 0) {
|
|
467
|
+
customSchema = customErrorAddsSchema ? { allOf: [converted, customErrorAddsSchema] } : converted
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
entries.push({ key: ek, schema: customSchema })
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
for (const [status, entries] of byStatus) {
|
|
474
|
+
if (responses[String(status)] === undefined) {
|
|
475
|
+
const standardKeys = entries.filter((e) => !e.schema).map((e) => e.key)
|
|
476
|
+
const customSchemas = entries.filter((e) => e.schema).map((e) => e.schema as Record<string, unknown>)
|
|
477
|
+
|
|
478
|
+
let schema: Record<string, unknown>
|
|
479
|
+
|
|
480
|
+
if (customSchemas.length === 0) {
|
|
481
|
+
schema = cloneJson(baseErrorJsonSchema)
|
|
482
|
+
const props = schema.properties as Record<string, unknown> | undefined
|
|
483
|
+
if (props?.error_key) {
|
|
484
|
+
props.error_key = { enum: standardKeys.sort(), type: "string" }
|
|
485
|
+
}
|
|
486
|
+
if (props?.status) {
|
|
487
|
+
props.status = { enum: [status], type: "integer" }
|
|
488
|
+
}
|
|
489
|
+
} else if (standardKeys.length === 0 && customSchemas.length === 1) {
|
|
490
|
+
schema = customSchemas[0]
|
|
491
|
+
} else {
|
|
492
|
+
const schemas: Record<string, unknown>[] = []
|
|
493
|
+
if (standardKeys.length > 0) {
|
|
494
|
+
const stdSchema = cloneJson(baseErrorJsonSchema)
|
|
495
|
+
const props = stdSchema.properties as Record<string, unknown> | undefined
|
|
496
|
+
if (props?.error_key) {
|
|
497
|
+
props.error_key = { enum: standardKeys.sort(), type: "string" }
|
|
498
|
+
}
|
|
499
|
+
if (props?.status) {
|
|
500
|
+
props.status = { enum: [status], type: "integer" }
|
|
501
|
+
}
|
|
502
|
+
schemas.push(stdSchema)
|
|
503
|
+
}
|
|
504
|
+
schemas.push(...customSchemas)
|
|
505
|
+
schema = { oneOf: schemas }
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
responses[String(status)] = {
|
|
509
|
+
content: {
|
|
510
|
+
"application/json": { schema },
|
|
511
|
+
},
|
|
512
|
+
description: entries
|
|
513
|
+
.map((e) => e.key)
|
|
514
|
+
.sort()
|
|
515
|
+
.join(", "),
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (Object.keys(responses).length === 0) {
|
|
522
|
+
responses["200"] = { description: "Success" }
|
|
523
|
+
}
|
|
524
|
+
operation.responses = responses
|
|
525
|
+
|
|
526
|
+
paths[oaPath][methodKey] = operation
|
|
527
|
+
emitted.push({
|
|
528
|
+
meta: handler.mt as Record<string, unknown> | null,
|
|
529
|
+
method: methodKey,
|
|
530
|
+
operation,
|
|
531
|
+
path: oaPath,
|
|
532
|
+
})
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const wsRoutes: CollectedWSRoute[] = []
|
|
536
|
+
walkWSRoutes(tree, "", wsRoutes)
|
|
537
|
+
|
|
538
|
+
for (const { handler, path } of wsRoutes) {
|
|
539
|
+
const oaPath = toOpenApiPath(path)
|
|
540
|
+
if (paths[oaPath] === undefined) {
|
|
541
|
+
paths[oaPath] = {}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const operation: Record<string, unknown> = { "x-websocket": true }
|
|
545
|
+
const parameters: Array<Record<string, unknown>> = []
|
|
546
|
+
|
|
547
|
+
applyMetaSpec({
|
|
548
|
+
collector,
|
|
549
|
+
filter: profileFilter,
|
|
550
|
+
kind: "ws",
|
|
551
|
+
meta: handler.mt,
|
|
552
|
+
method: "WS",
|
|
553
|
+
operation,
|
|
554
|
+
path,
|
|
555
|
+
profile: profileName,
|
|
556
|
+
schemaMeta: metaSpec.needsSchemas ? makeSchemaMetaLookup(handler as unknown as RouteHandler) : undefined,
|
|
557
|
+
spec: metaSpec,
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
const wsParams = extractParams(path)
|
|
561
|
+
for (const name of wsParams) {
|
|
562
|
+
parameters.push({
|
|
563
|
+
in: "path",
|
|
564
|
+
name,
|
|
565
|
+
required: true,
|
|
566
|
+
schema: { type: "string" },
|
|
567
|
+
})
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (handler.iv?.search) {
|
|
571
|
+
const searchEntry = handler.iv.search
|
|
572
|
+
const searchSchema =
|
|
573
|
+
"_tag" in searchEntry
|
|
574
|
+
? (searchEntry as { schema: StandardSchemaLike }).schema
|
|
575
|
+
: (searchEntry as StandardSchemaLike)
|
|
576
|
+
const jsonSchema = asJsonSchema(searchSchema as StandardSchemaLike | Record<string, unknown>, "input")
|
|
577
|
+
if (jsonSchema && typeof jsonSchema === "object") {
|
|
578
|
+
const props = jsonSchema.properties as Record<string, unknown> | undefined
|
|
579
|
+
const required = new Set((jsonSchema.required ?? []) as string[])
|
|
580
|
+
if (props) {
|
|
581
|
+
for (const [name, schema] of Object.entries(props)) {
|
|
582
|
+
parameters.push({
|
|
583
|
+
in: "query",
|
|
584
|
+
name,
|
|
585
|
+
required: required.has(name),
|
|
586
|
+
schema,
|
|
587
|
+
})
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (parameters.length > 0) operation.parameters = parameters
|
|
594
|
+
operation.responses = { "101": { description: "WebSocket upgrade" } }
|
|
595
|
+
|
|
596
|
+
paths[oaPath].get = operation
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
collector.flush()
|
|
600
|
+
reportMissingInvalidate(emitted, options.invalidate)
|
|
601
|
+
|
|
602
|
+
const result: OpenApiSpec = {
|
|
603
|
+
info: options.info,
|
|
604
|
+
openapi: "3.1.0",
|
|
605
|
+
paths,
|
|
606
|
+
}
|
|
607
|
+
if (options.securitySchemes) {
|
|
608
|
+
result.components = {
|
|
609
|
+
...result.components,
|
|
610
|
+
securitySchemes: options.securitySchemes,
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return result
|
|
614
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { StandardSchemaLike } from "../types.ts"
|
|
2
|
+
|
|
3
|
+
export type JsonSchemaConverter = (schema: StandardSchemaLike, io?: "input" | "output") => Record<string, unknown>
|
|
4
|
+
|
|
5
|
+
let converter: JsonSchemaConverter | undefined
|
|
6
|
+
|
|
7
|
+
export function setJsonSchemaConverter(next: JsonSchemaConverter | undefined): void {
|
|
8
|
+
converter = next
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getJsonSchemaConverter(): JsonSchemaConverter | undefined {
|
|
12
|
+
return converter
|
|
13
|
+
}
|
package/src/openapi/spec.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { generateOpenApi } from "../codegen.ts"
|
|
2
|
-
import type { OpenApiRouteInfo } from "../codegen.ts"
|
|
3
1
|
import type { DefaultMeta } from "../types.ts"
|
|
4
2
|
import type { TypedResponse } from "../response.ts"
|
|
3
|
+
import { generateOpenApiFromTree, type OpenApiRouteInfo } from "./document.ts"
|
|
4
|
+
import { tryGetOpenApiRuntime } from "./spec-factory.ts"
|
|
5
5
|
|
|
6
6
|
type SpecOptions<TMeta = Record<string, unknown> | null> = {
|
|
7
7
|
description?: string
|
|
@@ -21,15 +21,25 @@ export function spec<TMeta = Record<string, unknown> | null>(
|
|
|
21
21
|
const handler = async (ctx: { res: { json(sk: "ok", data: unknown): TypedResponse } }) => {
|
|
22
22
|
if (cached === null) {
|
|
23
23
|
const desc = Object.getOwnPropertyDescriptor(handler, Symbol.for("honey.app"))
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
24
|
+
const app = desc?.value
|
|
25
|
+
const generateOptions = {
|
|
26
|
+
filterRoutes: options.filterRoutes,
|
|
27
|
+
info: options,
|
|
28
|
+
/* a served document is not an authoring moment — the check belongs to `honey generate` */
|
|
29
|
+
invalidate: "off" as const,
|
|
30
|
+
profile: options.profile,
|
|
31
|
+
securitySchemes: options.securitySchemes,
|
|
32
|
+
}
|
|
33
|
+
const runtime = tryGetOpenApiRuntime()
|
|
34
|
+
const openApiSpec = app
|
|
35
|
+
? runtime
|
|
36
|
+
? await runtime.generateOpenApi(app, {
|
|
37
|
+
...generateOptions,
|
|
38
|
+
filterRoutes: options.filterRoutes as
|
|
39
|
+
| ((route: { meta: unknown; method: string; path: string }) => boolean)
|
|
40
|
+
| undefined,
|
|
41
|
+
})
|
|
42
|
+
: generateOpenApiFromTree(app, generateOptions)
|
|
33
43
|
: {}
|
|
34
44
|
cached = JSON.stringify(openApiSpec)
|
|
35
45
|
}
|