@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.
@@ -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
+ }
@@ -45,3 +45,7 @@ export function getOpenApiRuntime(): OpenApiRuntime {
45
45
  if (!runtime) throw new Error(MISSING)
46
46
  return runtime
47
47
  }
48
+
49
+ export function tryGetOpenApiRuntime(): OpenApiRuntime | undefined {
50
+ return runtime
51
+ }
@@ -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 openApiSpec = desc
25
- ? await generateOpenApi(desc.value, {
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",
30
- profile: options.profile,
31
- securitySchemes: options.securitySchemes,
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
  }