@lovrozagar/honey 0.5.2 → 0.5.3

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/src/codegen.ts CHANGED
@@ -6,40 +6,29 @@ import { sanitizeZodJsonSchema } from "./codegen-sanitize.ts"
6
6
  import { methodsOf, namespacesOf, schemaToIR, toIR } from "./codegen-ir.ts"
7
7
  import type { IRNamespace } from "./codegen-ir.ts"
8
8
  import type { HoneyError } from "./error.ts"
9
- import {
10
- type InvalidateCheckConfig,
11
- type InvalidateCheckOperation,
12
- reportMissingInvalidate,
13
- } from "./invalidate-check.ts"
14
- import {
15
- applyMetaSpec,
16
- compileMetaSpec,
17
- type CompiledMetaSpec,
18
- MetaSpecCollector,
19
- resolveProfile,
20
- type SchemaMetaHit,
21
- type SchemaMetaLookup,
22
- } from "./meta-spec.ts"
23
9
  import { ERROR_META } from "./errors.ts"
24
10
  import type { ErrorMetaEntry } from "./errors.ts"
11
+ import type { InvalidateCheckConfig } from "./invalidate-check.ts"
25
12
  import type { Honey } from "./index.ts"
13
+ import {
14
+ generateOpenApiFromTree,
15
+ type OpenApiInfo,
16
+ type OpenApiRouteInfo,
17
+ type OpenApiSpec,
18
+ } from "./openapi/document.ts"
19
+ import { getJsonSchemaConverter, setJsonSchemaConverter } from "./openapi/json-schema-slot.ts"
26
20
  import type { RouteHandler, TreeNode } from "./tree.ts"
27
21
  import { irToTs } from "./ts-type-emitter.ts"
28
22
  import { createTypeEmitState, emitSchemaType } from "./type-emitter.ts"
29
23
  import type { TypeEmitState } from "./type-emitter.ts"
30
- import type {
31
- InputSchemaEntry,
32
- InputSchemasDef,
33
- MetaSpecConfig,
34
- MetaSpecSchemaSource,
35
- OutputSchemaDef,
36
- StandardSchemaLike,
37
- } from "./types.ts"
24
+ import type { InputSchemaEntry, InputSchemasDef, OutputSchemaDef, StandardSchemaLike } from "./types.ts"
38
25
  import { EMPTY_OBJ, statusKeyToCode } from "./types.ts"
39
26
 
40
27
  export { prepareCodegen } from "./codegen-loaders.ts"
41
28
  export type { InvalidateCheckConfig, InvalidateCheckLevel } from "./invalidate-check.ts"
42
29
  export { toYaml, yamlSiblingPath } from "./yaml.ts"
30
+ export { DEFAULT_ERROR_JSON_SCHEMA } from "./openapi/document.ts"
31
+ export type { OpenApiInfo, OpenApiRouteInfo, OpenApiSpec }
43
32
 
44
33
  /* Status → typed error subclass. Single source of truth shared between the
45
34
  * emitted client (class declarations + status map + footer re-exports) and
@@ -283,22 +272,6 @@ type RouteManifest = {
283
272
  routes: RouteManifestEntry[]
284
273
  }
285
274
 
286
- type OpenApiInfo = {
287
- description?: string
288
- title: string
289
- version: string
290
- }
291
-
292
- export type OpenApiSpec = {
293
- components?: {
294
- schemas?: Record<string, Record<string, unknown>>
295
- securitySchemes?: Record<string, unknown>
296
- }
297
- info: OpenApiInfo
298
- openapi: string
299
- paths: Record<string, Record<string, Record<string, unknown>>>
300
- }
301
-
302
275
  function extractParams(path: string): string[] {
303
276
  const params: string[] = []
304
277
  for (const seg of path.split("/")) {
@@ -309,10 +282,6 @@ function extractParams(path: string): string[] {
309
282
  return params
310
283
  }
311
284
 
312
- function toOpenApiPath(path: string): string {
313
- return path.replace(/:(\w+)\??/g, "{$1}")
314
- }
315
-
316
285
  type CollectedRoute = {
317
286
  handler: RouteHandler
318
287
  method: string
@@ -347,23 +316,6 @@ function walkTree(node: TreeNode, currentPath: string, routes: CollectedRoute[],
347
316
  }
348
317
  }
349
318
 
350
- type CollectedWSRoute = {
351
- handler: import("./tree.ts").WSRouteHandler
352
- path: string
353
- }
354
-
355
- function walkWSRoutes(node: TreeNode, currentPath: string, routes: CollectedWSRoute[]): void {
356
- if (node.ws !== null) {
357
- routes.push({ handler: node.ws, path: currentPath || "/" })
358
- }
359
- for (const [seg, child] of Object.entries(node.s)) {
360
- walkWSRoutes(child, `${currentPath}/${seg}`, routes)
361
- }
362
- if (node.d !== null) {
363
- walkWSRoutes(node.d.c, `${currentPath}/:${node.d.n}`, routes)
364
- }
365
- }
366
-
367
319
  type ErrorInfo = { errorKey: string; status: number; statusKey: string }
368
320
 
369
321
  /* Longest suffix first so `*_not_found` wins over `*_found` (302). */
@@ -409,45 +361,6 @@ function getErrorFactory<TEnv, TCtx>(
409
361
  return (app as unknown as { _errorFactory: Record<string, () => HoneyError> | null })._errorFactory
410
362
  }
411
363
 
412
- export const DEFAULT_ERROR_JSON_SCHEMA: Record<string, unknown> = {
413
- properties: {
414
- error_key: { type: "string" },
415
- fields: {
416
- additionalProperties: {
417
- items: {
418
- properties: {
419
- error_key: { type: "string" },
420
- message: { type: "string" },
421
- path: { type: "string" },
422
- },
423
- required: ["error_key", "message", "path"],
424
- type: "object",
425
- },
426
- type: "array",
427
- },
428
- type: "object",
429
- },
430
- message: { type: "string" },
431
- status: { type: "integer" },
432
- status_key: { type: "string" },
433
- success: { const: false },
434
- },
435
- required: ["error_key", "fields", "message", "status", "status_key", "success"],
436
- type: "object",
437
- }
438
-
439
- function getErrorSchema<TEnv, TCtx>(
440
- app: Honey<TEnv, TCtx, unknown, unknown, unknown, string, string>,
441
- ): StandardSchemaLike | null {
442
- return (app as unknown as { _errorSchema: StandardSchemaLike | null })._errorSchema
443
- }
444
-
445
- function getCustomErrorSchema<TEnv, TCtx>(
446
- app: Honey<TEnv, TCtx, unknown, unknown, unknown, string, string>,
447
- ): StandardSchemaLike | null {
448
- return (app as unknown as { _customErrorSchema: StandardSchemaLike | null })._customErrorSchema
449
- }
450
-
451
364
  function getErrorMeta(factory: Record<string, () => HoneyError> | null): Record<string, ErrorMetaEntry> | null {
452
365
  if (!factory) return null
453
366
  return (factory as Record<symbol, Record<string, ErrorMetaEntry>>)[ERROR_META] ?? null
@@ -471,49 +384,6 @@ function introspectSchema(schema: StandardSchemaLike): unknown {
471
384
 
472
385
  export { normalizeSecurity } from "./meta-spec.ts"
473
386
 
474
- /* Strip properties marked with x-internal from an object JSON schema */
475
- function stripInternalProps(schema: Record<string, unknown>): Record<string, unknown> {
476
- const props = schema.properties as Record<string, Record<string, unknown>> | undefined
477
- if (!props) return schema
478
- const filtered: Record<string, unknown> = {}
479
- for (const [key, val] of Object.entries(props)) {
480
- if (val?.["x-internal"] === true) continue
481
- filtered[key] = val
482
- }
483
- const required = schema.required as string[] | undefined
484
- const result: Record<string, unknown> = { ...schema, properties: filtered }
485
- if (required) {
486
- result.required = required.filter((k) => filtered[k] !== undefined)
487
- }
488
- return result
489
- }
490
-
491
- const BINARY_FORM_FORMATS = new Set(["binary", "byte", "file"])
492
-
493
- /** File parts cannot ride application/x-www-form-urlencoded. */
494
- function isBinaryFormPart(schema: Record<string, unknown> | undefined): boolean {
495
- if (!schema) return false
496
- const format = schema.format
497
- if (typeof format === "string" && BINARY_FORM_FORMATS.has(format)) return true
498
- if (schema.type === "file") return true
499
- if (schema.type === "array") {
500
- const items = schema.items
501
- if (items !== null && typeof items === "object" && !Array.isArray(items)) {
502
- return isBinaryFormPart(items as Record<string, unknown>)
503
- }
504
- }
505
- return false
506
- }
507
-
508
- function formSchemaHasBinaryPart(schema: Record<string, unknown>): boolean {
509
- const props = schema.properties as Record<string, Record<string, unknown>> | undefined
510
- if (!props) return false
511
- for (const prop of Object.values(props)) {
512
- if (isBinaryFormPart(prop)) return true
513
- }
514
- return false
515
- }
516
-
517
387
  export function canonicalizeSchema(schema: Record<string, unknown>): string {
518
388
  return JSON.stringify(schema, (_, value) => {
519
389
  if (value && typeof value === "object" && !Array.isArray(value)) {
@@ -1307,16 +1177,6 @@ function schemaToJsonSchema(schema: StandardSchemaLike, io: "input" | "output" =
1307
1177
  return introspectSchema(schema)
1308
1178
  }
1309
1179
 
1310
- function asJsonSchema(
1311
- entry: StandardSchemaLike | Record<string, unknown>,
1312
- io: "input" | "output" = "output",
1313
- ): Record<string, unknown> {
1314
- if (entry !== null && entry !== undefined && "~standard" in (entry as object)) {
1315
- return schemaToJsonSchema(entry as StandardSchemaLike, io) as Record<string, unknown>
1316
- }
1317
- return entry as Record<string, unknown>
1318
- }
1319
-
1320
1180
  /* ---- Manifest generation ---- */
1321
1181
 
1322
1182
  export function generateManifest<TEnv, TCtx>(
@@ -1382,129 +1242,6 @@ export function generateManifest<TEnv, TCtx>(
1382
1242
 
1383
1243
  /* ---- OpenAPI generation ---- */
1384
1244
 
1385
- export type OpenApiRouteInfo<TMeta = unknown> = {
1386
- meta: TMeta
1387
- method: string
1388
- path: string
1389
- }
1390
-
1391
- function getMetaSpecConfig(app: unknown): MetaSpecConfig | null {
1392
- return (app as { _metaSpec?: MetaSpecConfig | null })._metaSpec ?? null
1393
- }
1394
-
1395
- /** Lowest declared 2xx JSON output schema — the "output" schema-meta source */
1396
- function primaryOutputSchema(handler: RouteHandler): StandardSchemaLike | null {
1397
- const json = handler.os?.["application/json"] as Record<string, unknown> | undefined
1398
- if (!json) return null
1399
- let best: { code: number; schema: StandardSchemaLike } | null = null
1400
- for (const [statusKey, schema] of Object.entries(json)) {
1401
- if (schema === undefined) continue
1402
- const code = statusKeyToCode[statusKey as keyof typeof statusKeyToCode]
1403
- if (!code || code < 200 || code > 299) continue
1404
- if (!best || code < best.code) best = { code, schema: schema as StandardSchemaLike }
1405
- }
1406
- return best?.schema ?? null
1407
- }
1408
-
1409
- /**
1410
- * Reads app keys off the *root* of a route schema's JSON Schema. Vendor-neutral: Zod's
1411
- * `.meta({ … })` lands on the JSON Schema root, and so does every other validator honey
1412
- * supports that carries schema metadata.
1413
- */
1414
- /** Child schema nodes a deep search descends into, in visit order */
1415
- function schemaChildren(node: Record<string, unknown>): Record<string, unknown>[] {
1416
- const out: Record<string, unknown>[] = []
1417
- const push = (value: unknown): void => {
1418
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1419
- out.push(value as Record<string, unknown>)
1420
- }
1421
- }
1422
- const props = node.properties
1423
- if (props !== null && typeof props === "object" && !Array.isArray(props)) {
1424
- for (const child of Object.values(props as Record<string, unknown>)) push(child)
1425
- }
1426
- push(node.items)
1427
- for (const key of ["allOf", "anyOf", "oneOf", "prefixItems"]) {
1428
- const members = node[key]
1429
- if (Array.isArray(members)) {
1430
- for (const member of members) push(member)
1431
- }
1432
- }
1433
- return out
1434
- }
1435
-
1436
- /** Bounded BFS — shallowest match wins; several distinct values at that depth is ambiguous */
1437
- function searchSchemaKey(root: Record<string, unknown>, key: string, maxDepth: number): SchemaMetaHit {
1438
- let level = [root]
1439
- for (let depth = 0; depth <= maxDepth && level.length > 0; depth++) {
1440
- const matches: unknown[] = []
1441
- const seen = new Set<string>()
1442
- for (const node of level) {
1443
- if (!Object.hasOwn(node, key) || node[key] === undefined) continue
1444
- const fingerprint = JSON.stringify(node[key]) ?? "undefined"
1445
- if (seen.has(fingerprint)) continue
1446
- seen.add(fingerprint)
1447
- matches.push(node[key])
1448
- }
1449
- if (matches.length === 1) return { found: true, value: matches[0] }
1450
- if (matches.length > 1) return { found: "ambiguous", values: matches }
1451
- const next: Record<string, unknown>[] = []
1452
- for (const node of level) next.push(...schemaChildren(node))
1453
- level = next
1454
- }
1455
- return { found: false }
1456
- }
1457
-
1458
- const DEEP_SEARCH_MAX_DEPTH = 6
1459
-
1460
- /**
1461
- * Resolves one schema-metadata key against one schema source. `"root"` reads the schema root,
1462
- * seeing through one level of `items` so a bare `array<Entity>` list output still finds the
1463
- * descriptor stamped on the item. `"deep"` walks the schema for envelope shapes.
1464
- */
1465
- function makeSchemaMetaLookup(handler: RouteHandler): SchemaMetaLookup {
1466
- const cache = new Map<string, Record<string, unknown> | undefined>()
1467
-
1468
- const rootOf = (source: MetaSpecSchemaSource): Record<string, unknown> | undefined => {
1469
- if (cache.has(source)) return cache.get(source)
1470
- let schema: StandardSchemaLike | null = null
1471
- let io: "input" | "output" = "output"
1472
- if (source === "output") {
1473
- schema = primaryOutputSchema(handler)
1474
- } else {
1475
- io = "input"
1476
- const key = source.slice("input.".length) as keyof InputSchemasDef
1477
- const entry = handler.iv?.[key]
1478
- schema = entry ? unwrapEntry(entry as InputSchemaEntry) : null
1479
- }
1480
- let root: Record<string, unknown> | undefined
1481
- if (schema) {
1482
- const converted = asJsonSchema(schema, io)
1483
- root = converted && typeof converted === "object" ? converted : undefined
1484
- }
1485
- cache.set(source, root)
1486
- return root
1487
- }
1488
-
1489
- return (source, key, search) => {
1490
- const root = rootOf(source)
1491
- if (!root) return { found: false }
1492
- if (search === "deep") return searchSchemaKey(root, key, DEEP_SEARCH_MAX_DEPTH)
1493
- if (Object.hasOwn(root, key) && root[key] !== undefined) return { found: true, value: root[key] }
1494
- /* a list output is `array<Entity>` — the descriptor sits on the item */
1495
- if (root.type === "array") {
1496
- const items = root.items
1497
- if (items !== null && typeof items === "object" && !Array.isArray(items)) {
1498
- const itemRoot = items as Record<string, unknown>
1499
- if (Object.hasOwn(itemRoot, key) && itemRoot[key] !== undefined) {
1500
- return { found: true, value: itemRoot[key] }
1501
- }
1502
- }
1503
- }
1504
- return { found: false }
1505
- }
1506
- }
1507
-
1508
1245
  export async function generateOpenApi<TEnv, TCtx, TMeta = unknown>(
1509
1246
  app: Honey<TEnv, TCtx, unknown, TMeta, unknown, string, string>,
1510
1247
  options: {
@@ -1522,347 +1259,14 @@ export async function generateOpenApi<TEnv, TCtx, TMeta = unknown>(
1522
1259
  },
1523
1260
  ): Promise<OpenApiSpec> {
1524
1261
  await Promise.all([loadToJSONSchema(), loadEffectJsonSchema()])
1525
- const collector = new MetaSpecCollector()
1526
- const metaSpec: CompiledMetaSpec = compileMetaSpec(getMetaSpecConfig(app), collector)
1527
- const { filter: profileFilter, name: profileName } = resolveProfile(metaSpec, options.profile, collector)
1528
- const factory = getErrorFactory(app)
1529
- const errorMeta = getErrorMeta(factory)
1530
-
1531
- /* resolve error response schema — custom StandardSchema → JSON Schema, or default */
1532
- const rawErrorSchema = getErrorSchema(app)
1533
- const baseErrorJsonSchema = rawErrorSchema
1534
- ? (schemaToJsonSchema(rawErrorSchema) as Record<string, unknown>)
1535
- : DEFAULT_ERROR_JSON_SCHEMA
1536
-
1537
- /* resolve custom error formatter schema (for merging into custom schema errors) */
1538
- const rawCustomErrorSchema = getCustomErrorSchema(app)
1539
- const customErrorAddsSchema = rawCustomErrorSchema
1540
- ? (schemaToJsonSchema(rawCustomErrorSchema) as Record<string, unknown>)
1541
- : null
1542
-
1543
- const tree = (app as unknown as { _tree: TreeNode })._tree
1544
- const collected: CollectedRoute[] = []
1545
- walkTree(tree, "", collected)
1546
-
1547
- const routeFilter = options.filterRoutes
1548
- const paths: Record<string, Record<string, Record<string, unknown>>> = {}
1549
- /* operations as emitted, for whole-document checks that need to see siblings */
1550
- const emitted: InvalidateCheckOperation[] = []
1551
-
1552
- for (const { handler, method, path } of collected) {
1553
- if (routeFilter) {
1554
- const meta = handler.mt as TMeta
1555
- if (!routeFilter({ meta, method, path })) continue
1556
- }
1557
- const oaPath = toOpenApiPath(path)
1558
- if (paths[oaPath] === undefined) {
1559
- paths[oaPath] = {}
1560
- }
1561
-
1562
- const methodKey = method.toLowerCase()
1563
- const operation: Record<string, unknown> = {}
1564
- const responses: Record<string, unknown> = {}
1565
- const parameters: Array<Record<string, unknown>> = []
1566
-
1567
- /* meta + schema meta → openApi operation fields, per the declared policy */
1568
- applyMetaSpec({
1569
- collector,
1570
- filter: profileFilter,
1571
- kind: "http",
1572
- meta: handler.mt as Record<string, unknown> | null,
1573
- method,
1574
- operation,
1575
- path,
1576
- profile: profileName,
1577
- schemaMeta: metaSpec.needsSchemas ? makeSchemaMetaLookup(handler) : undefined,
1578
- spec: metaSpec,
1579
- })
1580
-
1581
- /* path params */
1582
- const params = extractParams(path)
1583
- for (const name of params) {
1584
- const isOptional = path.includes(`:${name}?`)
1585
- parameters.push({
1586
- in: "path",
1587
- name,
1588
- required: !isOptional,
1589
- schema: { type: "string" },
1590
- })
1591
- }
1592
-
1593
- /* input → requestBody / parameters */
1594
- if (handler.iv) {
1595
- for (const [source, entry] of Object.entries(handler.iv)) {
1596
- if (entry === undefined) continue
1597
- /* readableStream() is a runtime tag; the document describes the inner schema */
1598
- const unwrapped = unwrapEntry(entry as InputSchemaEntry)
1599
- const jsonSchema = asJsonSchema(unwrapped, "input")
1600
-
1601
- if (source === "json" || source === "form") {
1602
- const schema = stripInternalProps(jsonSchema)
1603
- const content: Record<string, { schema: Record<string, unknown> }> = {}
1604
- if (source === "form") {
1605
- /* z.file() is format:binary — illegal on urlencoded; oat only builds FormData for multipart/* */
1606
- if (formSchemaHasBinaryPart(schema)) {
1607
- content["multipart/form-data"] = { schema }
1608
- }
1609
- content["application/x-www-form-urlencoded"] = { schema }
1610
- } else {
1611
- content["application/json"] = { schema }
1612
- }
1613
- operation.requestBody = {
1614
- content,
1615
- required: true,
1616
- }
1617
- } else if (source === "search" || source === "headers" || source === "cookies") {
1618
- let location: "cookie" | "header" | "query" = "cookie"
1619
- if (source === "search") location = "query"
1620
- else if (source === "headers") location = "header"
1621
- /* decompose object schema properties into individual parameters */
1622
- const props = jsonSchema.properties as Record<string, unknown> | undefined
1623
- const required = (jsonSchema.required as string[]) ?? []
1624
- if (props) {
1625
- for (const [propName, propSchema] of Object.entries(props)) {
1626
- if ((propSchema as Record<string, unknown>)?.["x-internal"] === true) continue
1627
- parameters.push({
1628
- in: location,
1629
- name: propName,
1630
- required: required.includes(propName),
1631
- schema: propSchema,
1632
- })
1633
- }
1634
- }
1635
- } else if (source === "params") {
1636
- /* override path param schemas with actual schema types */
1637
- const props = jsonSchema.properties as Record<string, unknown> | undefined
1638
- if (props) {
1639
- for (const param of parameters) {
1640
- const propSchema = props[param.name as string]
1641
- if (propSchema && param.in === "path") {
1642
- param.schema = propSchema
1643
- }
1644
- }
1645
- }
1646
- }
1647
- }
1648
- }
1649
-
1650
- if (parameters.length > 0) {
1651
- operation.parameters = parameters
1652
- }
1653
-
1654
- /* output → responses */
1655
- if (handler.os) {
1656
- for (const [contentType, schemas] of Object.entries(handler.os)) {
1657
- if (schemas === undefined) continue
1658
- if (contentType === "redirect") {
1659
- for (const statusKey of Object.keys(schemas)) {
1660
- const statusCode = statusKeyToCode[statusKey as keyof typeof statusKeyToCode]
1661
- if (statusCode) {
1662
- responses[String(statusCode)] = {
1663
- description: statusKey.replace(/_/g, " "),
1664
- headers: {
1665
- Location: {
1666
- description: "Redirect target URL",
1667
- schema: { format: "uri", type: "string" },
1668
- },
1669
- },
1670
- }
1671
- }
1672
- }
1673
- continue
1674
- }
1675
- for (const [statusKey, schema] of Object.entries(schemas)) {
1676
- if (schema === undefined) continue
1677
- const statusCode = statusKeyToCode[statusKey as keyof typeof statusKeyToCode]
1678
- if (statusCode) {
1679
- responses[String(statusCode)] = {
1680
- content: {
1681
- [contentType]: {
1682
- schema: asJsonSchema(schema as StandardSchemaLike | Record<string, unknown>),
1683
- },
1684
- },
1685
- description: statusKey.replace(/_/g, " "),
1686
- }
1687
- }
1688
- }
1689
- }
1690
- }
1691
-
1692
- /* error responses from declared error keys */
1693
- if (handler.ek.size > 0) {
1694
- type ErrorEntry = { key: string; schema: Record<string, unknown> | null }
1695
- const byStatus = new Map<number, ErrorEntry[]>()
1696
- for (const ek of handler.ek) {
1697
- const info = resolveErrorInfo(ek, factory)
1698
- if (info.status > 0) {
1699
- let entries = byStatus.get(info.status)
1700
- if (!entries) {
1701
- entries = []
1702
- byStatus.set(info.status, entries)
1703
- }
1704
- /* check if this error has a custom schema */
1705
- const meta = errorMeta?.[ek]
1706
- let customSchema: Record<string, unknown> | null = null
1707
- if (meta?.schema) {
1708
- const converted = schemaToJsonSchema(meta.schema as StandardSchemaLike)
1709
- if (converted) {
1710
- customSchema = customErrorAddsSchema
1711
- ? { allOf: [converted, customErrorAddsSchema] }
1712
- : (converted as Record<string, unknown>)
1713
- }
1714
- }
1715
- entries.push({ key: ek, schema: customSchema })
1716
- }
1717
- }
1718
- for (const [status, entries] of byStatus) {
1719
- if (responses[String(status)] === undefined) {
1720
- const standardKeys = entries.filter((e) => !e.schema).map((e) => e.key)
1721
- const customSchemas = entries.filter((e) => e.schema).map((e) => e.schema as Record<string, unknown>)
1722
-
1723
- let schema: Record<string, unknown>
1724
-
1725
- if (customSchemas.length === 0) {
1726
- /* all standard errors at this status — use base error schema with constrained enums */
1727
- schema = cloneJson(baseErrorJsonSchema)
1728
- const props = schema.properties as Record<string, unknown> | undefined
1729
- if (props?.error_key) {
1730
- props.error_key = { enum: standardKeys.sort(), type: "string" }
1731
- }
1732
- if (props?.status) {
1733
- props.status = { enum: [status], type: "integer" }
1734
- }
1735
- } else if (standardKeys.length === 0 && customSchemas.length === 1) {
1736
- /* single custom schema error — use it directly */
1737
- schema = customSchemas[0]
1738
- } else {
1739
- /* mixed standard + custom, or multiple custom — use oneOf */
1740
- const schemas: Record<string, unknown>[] = []
1741
- if (standardKeys.length > 0) {
1742
- const stdSchema = cloneJson(baseErrorJsonSchema)
1743
- const props = stdSchema.properties as Record<string, unknown> | undefined
1744
- if (props?.error_key) {
1745
- props.error_key = { enum: standardKeys.sort(), type: "string" }
1746
- }
1747
- if (props?.status) {
1748
- props.status = { enum: [status], type: "integer" }
1749
- }
1750
- schemas.push(stdSchema)
1751
- }
1752
- schemas.push(...customSchemas)
1753
- schema = { oneOf: schemas }
1754
- }
1755
-
1756
- responses[String(status)] = {
1757
- content: {
1758
- "application/json": { schema },
1759
- },
1760
- description: entries
1761
- .map((e) => e.key)
1762
- .sort()
1763
- .join(", "),
1764
- }
1765
- }
1766
- }
1767
- }
1768
-
1769
- if (Object.keys(responses).length === 0) {
1770
- responses["200"] = { description: "Success" }
1771
- }
1772
- operation.responses = responses
1773
-
1774
- paths[oaPath][methodKey] = operation
1775
- emitted.push({
1776
- meta: handler.mt as Record<string, unknown> | null,
1777
- method: methodKey,
1778
- operation,
1779
- path: oaPath,
1780
- })
1781
- }
1782
-
1783
- /* WS routes */
1784
- const wsRoutes: CollectedWSRoute[] = []
1785
- walkWSRoutes(tree, "", wsRoutes)
1786
-
1787
- for (const { handler, path } of wsRoutes) {
1788
- const oaPath = toOpenApiPath(path)
1789
- if (paths[oaPath] === undefined) {
1790
- paths[oaPath] = {}
1791
- }
1792
-
1793
- const operation: Record<string, unknown> = { "x-websocket": true }
1794
- const parameters: Array<Record<string, unknown>> = []
1795
-
1796
- applyMetaSpec({
1797
- collector,
1798
- filter: profileFilter,
1799
- kind: "ws",
1800
- meta: handler.mt as Record<string, unknown> | null,
1801
- method: "WS",
1802
- operation,
1803
- path,
1804
- profile: profileName,
1805
- schemaMeta: metaSpec.needsSchemas ? makeSchemaMetaLookup(handler as unknown as RouteHandler) : undefined,
1806
- spec: metaSpec,
1807
- })
1808
-
1809
- /* path params */
1810
- const wsParams = extractParams(path)
1811
- for (const name of wsParams) {
1812
- parameters.push({
1813
- in: "path",
1814
- name,
1815
- required: true,
1816
- schema: { type: "string" },
1817
- })
1818
- }
1819
-
1820
- /* query params from input schemas — use same conversion as HTTP routes */
1821
- if (handler.iv?.search) {
1822
- const searchEntry = handler.iv.search
1823
- const searchSchema =
1824
- "_tag" in searchEntry
1825
- ? (searchEntry as { schema: StandardSchemaLike }).schema
1826
- : (searchEntry as StandardSchemaLike)
1827
- const jsonSchema = asJsonSchema(searchSchema as StandardSchemaLike | Record<string, unknown>, "input")
1828
- if (jsonSchema && typeof jsonSchema === "object") {
1829
- const props = (jsonSchema as Record<string, unknown>).properties as Record<string, unknown> | undefined
1830
- const required = new Set(((jsonSchema as Record<string, unknown>).required ?? []) as string[])
1831
- if (props) {
1832
- for (const [name, schema] of Object.entries(props)) {
1833
- parameters.push({
1834
- in: "query",
1835
- name,
1836
- required: required.has(name),
1837
- schema,
1838
- })
1839
- }
1840
- }
1841
- }
1842
- }
1843
-
1844
- if (parameters.length > 0) operation.parameters = parameters
1845
- operation.responses = { "101": { description: "WebSocket upgrade" } }
1846
-
1847
- paths[oaPath].get = operation
1848
- }
1849
-
1850
- collector.flush()
1851
- /* after the policy is known good — a broken policy makes this report meaningless */
1852
- reportMissingInvalidate(emitted, options.invalidate)
1853
-
1854
- const result: OpenApiSpec = {
1855
- info: options.info,
1856
- openapi: "3.1.0",
1857
- paths,
1858
- }
1859
- if (options.securitySchemes) {
1860
- result.components = {
1861
- ...result.components,
1862
- securitySchemes: options.securitySchemes,
1863
- }
1262
+ const prev = getJsonSchemaConverter()
1263
+ setJsonSchemaConverter((schema, io) => schemaToJsonSchema(schema, io) as Record<string, unknown>)
1264
+ try {
1265
+ const spec = generateOpenApiFromTree(app, options)
1266
+ return deduplicateSchemas(spec)
1267
+ } finally {
1268
+ setJsonSchemaConverter(prev)
1864
1269
  }
1865
- return deduplicateSchemas(result)
1866
1270
  }
1867
1271
 
1868
1272
  export function extractSchemas<TEnv, TCtx>(
@@ -2198,7 +1602,7 @@ function emitRouteTree(routes: RouteConfig[], collected: CollectedRoute[] | null
2198
1602
  const osExpr = route.outputSchemas !== null ? intern.expr(route.outputSchemas) : "null"
2199
1603
  const mtExpr = route.meta !== null ? intern.expr(route.meta) : "null"
2200
1604
  lines.push(
2201
- `const H${i}: RouteHandler = { bek: ${bekExpr}, ef: null, fn: FN, mw: [], ek: ${ekSet}, iv: ${ivExpr}, os: ${osExpr}, mt: ${mtExpr}, ov: null, rp: "" }`,
1605
+ `const H${i}: RouteHandler = { bek: ${bekExpr}, fn: FN, mw: [], ek: ${ekSet}, iv: ${ivExpr}, os: ${osExpr}, mt: ${mtExpr} }`,
2202
1606
  )
2203
1607
  }
2204
1608
  }