@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/README.md +10 -8
- 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/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/tree.ts +5 -5
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
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
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},
|
|
1605
|
+
`const H${i}: RouteHandler = { bek: ${bekExpr}, fn: FN, mw: [], ek: ${ekSet}, iv: ${ivExpr}, os: ${osExpr}, mt: ${mtExpr} }`,
|
|
2202
1606
|
)
|
|
2203
1607
|
}
|
|
2204
1608
|
}
|