@lovrozagar/honey 0.5.1 → 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 +11 -9
- package/dist/codegen-route-tree-intern.d.ts +29 -0
- package/dist/codegen-route-tree-intern.d.ts.map +1 -0
- package/dist/codegen.d.ts +5 -22
- 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 -39
- package/dist/tree.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/codegen-route-tree-intern.ts +171 -0
- package/src/codegen.ts +210 -728
- 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 -398
package/src/codegen.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { InternPool } from "./codegen-route-tree-intern.ts"
|
|
1
2
|
import { deriveErrorEnvelopeName, deriveSchemaName, isErrorEnvelope, shortHash } from "./codegen-schema-naming.ts"
|
|
2
3
|
import type { SchemaNameContext } from "./codegen-schema-naming.ts"
|
|
3
4
|
import { effectJsonSchemaFn, loadEffectJsonSchema, loadToJSONSchema, toJSONSchemaFn } from "./codegen-loaders.ts"
|
|
@@ -5,47 +6,29 @@ import { sanitizeZodJsonSchema } from "./codegen-sanitize.ts"
|
|
|
5
6
|
import { methodsOf, namespacesOf, schemaToIR, toIR } from "./codegen-ir.ts"
|
|
6
7
|
import type { IRNamespace } from "./codegen-ir.ts"
|
|
7
8
|
import type { HoneyError } from "./error.ts"
|
|
8
|
-
import {
|
|
9
|
-
type InvalidateCheckConfig,
|
|
10
|
-
type InvalidateCheckOperation,
|
|
11
|
-
reportMissingInvalidate,
|
|
12
|
-
} from "./invalidate-check.ts"
|
|
13
|
-
import {
|
|
14
|
-
applyMetaSpec,
|
|
15
|
-
compileMetaSpec,
|
|
16
|
-
type CompiledMetaSpec,
|
|
17
|
-
MetaSpecCollector,
|
|
18
|
-
resolveProfile,
|
|
19
|
-
type SchemaMetaHit,
|
|
20
|
-
type SchemaMetaLookup,
|
|
21
|
-
} from "./meta-spec.ts"
|
|
22
9
|
import { ERROR_META } from "./errors.ts"
|
|
23
10
|
import type { ErrorMetaEntry } from "./errors.ts"
|
|
11
|
+
import type { InvalidateCheckConfig } from "./invalidate-check.ts"
|
|
24
12
|
import type { Honey } from "./index.ts"
|
|
25
13
|
import {
|
|
26
|
-
|
|
27
|
-
type
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
} from "./tree.ts"
|
|
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"
|
|
20
|
+
import type { RouteHandler, TreeNode } from "./tree.ts"
|
|
33
21
|
import { irToTs } from "./ts-type-emitter.ts"
|
|
34
22
|
import { createTypeEmitState, emitSchemaType } from "./type-emitter.ts"
|
|
35
23
|
import type { TypeEmitState } from "./type-emitter.ts"
|
|
36
|
-
import type {
|
|
37
|
-
InputSchemaEntry,
|
|
38
|
-
InputSchemasDef,
|
|
39
|
-
MetaSpecConfig,
|
|
40
|
-
MetaSpecSchemaSource,
|
|
41
|
-
OutputSchemaDef,
|
|
42
|
-
StandardSchemaLike,
|
|
43
|
-
} from "./types.ts"
|
|
24
|
+
import type { InputSchemaEntry, InputSchemasDef, OutputSchemaDef, StandardSchemaLike } from "./types.ts"
|
|
44
25
|
import { EMPTY_OBJ, statusKeyToCode } from "./types.ts"
|
|
45
26
|
|
|
46
27
|
export { prepareCodegen } from "./codegen-loaders.ts"
|
|
47
28
|
export type { InvalidateCheckConfig, InvalidateCheckLevel } from "./invalidate-check.ts"
|
|
48
29
|
export { toYaml, yamlSiblingPath } from "./yaml.ts"
|
|
30
|
+
export { DEFAULT_ERROR_JSON_SCHEMA } from "./openapi/document.ts"
|
|
31
|
+
export type { OpenApiInfo, OpenApiRouteInfo, OpenApiSpec }
|
|
49
32
|
|
|
50
33
|
/* Status → typed error subclass. Single source of truth shared between the
|
|
51
34
|
* emitted client (class declarations + status map + footer re-exports) and
|
|
@@ -289,22 +272,6 @@ type RouteManifest = {
|
|
|
289
272
|
routes: RouteManifestEntry[]
|
|
290
273
|
}
|
|
291
274
|
|
|
292
|
-
type OpenApiInfo = {
|
|
293
|
-
description?: string
|
|
294
|
-
title: string
|
|
295
|
-
version: string
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
export type OpenApiSpec = {
|
|
299
|
-
components?: {
|
|
300
|
-
schemas?: Record<string, Record<string, unknown>>
|
|
301
|
-
securitySchemes?: Record<string, unknown>
|
|
302
|
-
}
|
|
303
|
-
info: OpenApiInfo
|
|
304
|
-
openapi: string
|
|
305
|
-
paths: Record<string, Record<string, Record<string, unknown>>>
|
|
306
|
-
}
|
|
307
|
-
|
|
308
275
|
function extractParams(path: string): string[] {
|
|
309
276
|
const params: string[] = []
|
|
310
277
|
for (const seg of path.split("/")) {
|
|
@@ -315,10 +282,6 @@ function extractParams(path: string): string[] {
|
|
|
315
282
|
return params
|
|
316
283
|
}
|
|
317
284
|
|
|
318
|
-
function toOpenApiPath(path: string): string {
|
|
319
|
-
return path.replace(/:(\w+)\??/g, "{$1}")
|
|
320
|
-
}
|
|
321
|
-
|
|
322
285
|
type CollectedRoute = {
|
|
323
286
|
handler: RouteHandler
|
|
324
287
|
method: string
|
|
@@ -353,23 +316,6 @@ function walkTree(node: TreeNode, currentPath: string, routes: CollectedRoute[],
|
|
|
353
316
|
}
|
|
354
317
|
}
|
|
355
318
|
|
|
356
|
-
type CollectedWSRoute = {
|
|
357
|
-
handler: import("./tree.ts").WSRouteHandler
|
|
358
|
-
path: string
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
function walkWSRoutes(node: TreeNode, currentPath: string, routes: CollectedWSRoute[]): void {
|
|
362
|
-
if (node.ws !== null) {
|
|
363
|
-
routes.push({ handler: node.ws, path: currentPath || "/" })
|
|
364
|
-
}
|
|
365
|
-
for (const [seg, child] of Object.entries(node.s)) {
|
|
366
|
-
walkWSRoutes(child, `${currentPath}/${seg}`, routes)
|
|
367
|
-
}
|
|
368
|
-
if (node.d !== null) {
|
|
369
|
-
walkWSRoutes(node.d.c, `${currentPath}/:${node.d.n}`, routes)
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
319
|
type ErrorInfo = { errorKey: string; status: number; statusKey: string }
|
|
374
320
|
|
|
375
321
|
/* Longest suffix first so `*_not_found` wins over `*_found` (302). */
|
|
@@ -415,45 +361,6 @@ function getErrorFactory<TEnv, TCtx>(
|
|
|
415
361
|
return (app as unknown as { _errorFactory: Record<string, () => HoneyError> | null })._errorFactory
|
|
416
362
|
}
|
|
417
363
|
|
|
418
|
-
export const DEFAULT_ERROR_JSON_SCHEMA: Record<string, unknown> = {
|
|
419
|
-
properties: {
|
|
420
|
-
error_key: { type: "string" },
|
|
421
|
-
fields: {
|
|
422
|
-
additionalProperties: {
|
|
423
|
-
items: {
|
|
424
|
-
properties: {
|
|
425
|
-
error_key: { type: "string" },
|
|
426
|
-
message: { type: "string" },
|
|
427
|
-
path: { type: "string" },
|
|
428
|
-
},
|
|
429
|
-
required: ["error_key", "message", "path"],
|
|
430
|
-
type: "object",
|
|
431
|
-
},
|
|
432
|
-
type: "array",
|
|
433
|
-
},
|
|
434
|
-
type: "object",
|
|
435
|
-
},
|
|
436
|
-
message: { type: "string" },
|
|
437
|
-
status: { type: "integer" },
|
|
438
|
-
status_key: { type: "string" },
|
|
439
|
-
success: { const: false },
|
|
440
|
-
},
|
|
441
|
-
required: ["error_key", "fields", "message", "status", "status_key", "success"],
|
|
442
|
-
type: "object",
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
function getErrorSchema<TEnv, TCtx>(
|
|
446
|
-
app: Honey<TEnv, TCtx, unknown, unknown, unknown, string, string>,
|
|
447
|
-
): StandardSchemaLike | null {
|
|
448
|
-
return (app as unknown as { _errorSchema: StandardSchemaLike | null })._errorSchema
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
function getCustomErrorSchema<TEnv, TCtx>(
|
|
452
|
-
app: Honey<TEnv, TCtx, unknown, unknown, unknown, string, string>,
|
|
453
|
-
): StandardSchemaLike | null {
|
|
454
|
-
return (app as unknown as { _customErrorSchema: StandardSchemaLike | null })._customErrorSchema
|
|
455
|
-
}
|
|
456
|
-
|
|
457
364
|
function getErrorMeta(factory: Record<string, () => HoneyError> | null): Record<string, ErrorMetaEntry> | null {
|
|
458
365
|
if (!factory) return null
|
|
459
366
|
return (factory as Record<symbol, Record<string, ErrorMetaEntry>>)[ERROR_META] ?? null
|
|
@@ -477,49 +384,6 @@ function introspectSchema(schema: StandardSchemaLike): unknown {
|
|
|
477
384
|
|
|
478
385
|
export { normalizeSecurity } from "./meta-spec.ts"
|
|
479
386
|
|
|
480
|
-
/* Strip properties marked with x-internal from an object JSON schema */
|
|
481
|
-
function stripInternalProps(schema: Record<string, unknown>): Record<string, unknown> {
|
|
482
|
-
const props = schema.properties as Record<string, Record<string, unknown>> | undefined
|
|
483
|
-
if (!props) return schema
|
|
484
|
-
const filtered: Record<string, unknown> = {}
|
|
485
|
-
for (const [key, val] of Object.entries(props)) {
|
|
486
|
-
if (val?.["x-internal"] === true) continue
|
|
487
|
-
filtered[key] = val
|
|
488
|
-
}
|
|
489
|
-
const required = schema.required as string[] | undefined
|
|
490
|
-
const result: Record<string, unknown> = { ...schema, properties: filtered }
|
|
491
|
-
if (required) {
|
|
492
|
-
result.required = required.filter((k) => filtered[k] !== undefined)
|
|
493
|
-
}
|
|
494
|
-
return result
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
const BINARY_FORM_FORMATS = new Set(["binary", "byte", "file"])
|
|
498
|
-
|
|
499
|
-
/** File parts cannot ride application/x-www-form-urlencoded. */
|
|
500
|
-
function isBinaryFormPart(schema: Record<string, unknown> | undefined): boolean {
|
|
501
|
-
if (!schema) return false
|
|
502
|
-
const format = schema.format
|
|
503
|
-
if (typeof format === "string" && BINARY_FORM_FORMATS.has(format)) return true
|
|
504
|
-
if (schema.type === "file") return true
|
|
505
|
-
if (schema.type === "array") {
|
|
506
|
-
const items = schema.items
|
|
507
|
-
if (items !== null && typeof items === "object" && !Array.isArray(items)) {
|
|
508
|
-
return isBinaryFormPart(items as Record<string, unknown>)
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
return false
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
function formSchemaHasBinaryPart(schema: Record<string, unknown>): boolean {
|
|
515
|
-
const props = schema.properties as Record<string, Record<string, unknown>> | undefined
|
|
516
|
-
if (!props) return false
|
|
517
|
-
for (const prop of Object.values(props)) {
|
|
518
|
-
if (isBinaryFormPart(prop)) return true
|
|
519
|
-
}
|
|
520
|
-
return false
|
|
521
|
-
}
|
|
522
|
-
|
|
523
387
|
export function canonicalizeSchema(schema: Record<string, unknown>): string {
|
|
524
388
|
return JSON.stringify(schema, (_, value) => {
|
|
525
389
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
@@ -1313,16 +1177,6 @@ function schemaToJsonSchema(schema: StandardSchemaLike, io: "input" | "output" =
|
|
|
1313
1177
|
return introspectSchema(schema)
|
|
1314
1178
|
}
|
|
1315
1179
|
|
|
1316
|
-
function asJsonSchema(
|
|
1317
|
-
entry: StandardSchemaLike | Record<string, unknown>,
|
|
1318
|
-
io: "input" | "output" = "output",
|
|
1319
|
-
): Record<string, unknown> {
|
|
1320
|
-
if (entry !== null && entry !== undefined && "~standard" in (entry as object)) {
|
|
1321
|
-
return schemaToJsonSchema(entry as StandardSchemaLike, io) as Record<string, unknown>
|
|
1322
|
-
}
|
|
1323
|
-
return entry as Record<string, unknown>
|
|
1324
|
-
}
|
|
1325
|
-
|
|
1326
1180
|
/* ---- Manifest generation ---- */
|
|
1327
1181
|
|
|
1328
1182
|
export function generateManifest<TEnv, TCtx>(
|
|
@@ -1388,129 +1242,6 @@ export function generateManifest<TEnv, TCtx>(
|
|
|
1388
1242
|
|
|
1389
1243
|
/* ---- OpenAPI generation ---- */
|
|
1390
1244
|
|
|
1391
|
-
export type OpenApiRouteInfo<TMeta = unknown> = {
|
|
1392
|
-
meta: TMeta
|
|
1393
|
-
method: string
|
|
1394
|
-
path: string
|
|
1395
|
-
}
|
|
1396
|
-
|
|
1397
|
-
function getMetaSpecConfig(app: unknown): MetaSpecConfig | null {
|
|
1398
|
-
return (app as { _metaSpec?: MetaSpecConfig | null })._metaSpec ?? null
|
|
1399
|
-
}
|
|
1400
|
-
|
|
1401
|
-
/** Lowest declared 2xx JSON output schema — the "output" schema-meta source */
|
|
1402
|
-
function primaryOutputSchema(handler: RouteHandler): StandardSchemaLike | null {
|
|
1403
|
-
const json = handler.os?.["application/json"] as Record<string, unknown> | undefined
|
|
1404
|
-
if (!json) return null
|
|
1405
|
-
let best: { code: number; schema: StandardSchemaLike } | null = null
|
|
1406
|
-
for (const [statusKey, schema] of Object.entries(json)) {
|
|
1407
|
-
if (schema === undefined) continue
|
|
1408
|
-
const code = statusKeyToCode[statusKey as keyof typeof statusKeyToCode]
|
|
1409
|
-
if (!code || code < 200 || code > 299) continue
|
|
1410
|
-
if (!best || code < best.code) best = { code, schema: schema as StandardSchemaLike }
|
|
1411
|
-
}
|
|
1412
|
-
return best?.schema ?? null
|
|
1413
|
-
}
|
|
1414
|
-
|
|
1415
|
-
/**
|
|
1416
|
-
* Reads app keys off the *root* of a route schema's JSON Schema. Vendor-neutral: Zod's
|
|
1417
|
-
* `.meta({ … })` lands on the JSON Schema root, and so does every other validator honey
|
|
1418
|
-
* supports that carries schema metadata.
|
|
1419
|
-
*/
|
|
1420
|
-
/** Child schema nodes a deep search descends into, in visit order */
|
|
1421
|
-
function schemaChildren(node: Record<string, unknown>): Record<string, unknown>[] {
|
|
1422
|
-
const out: Record<string, unknown>[] = []
|
|
1423
|
-
const push = (value: unknown): void => {
|
|
1424
|
-
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
1425
|
-
out.push(value as Record<string, unknown>)
|
|
1426
|
-
}
|
|
1427
|
-
}
|
|
1428
|
-
const props = node.properties
|
|
1429
|
-
if (props !== null && typeof props === "object" && !Array.isArray(props)) {
|
|
1430
|
-
for (const child of Object.values(props as Record<string, unknown>)) push(child)
|
|
1431
|
-
}
|
|
1432
|
-
push(node.items)
|
|
1433
|
-
for (const key of ["allOf", "anyOf", "oneOf", "prefixItems"]) {
|
|
1434
|
-
const members = node[key]
|
|
1435
|
-
if (Array.isArray(members)) {
|
|
1436
|
-
for (const member of members) push(member)
|
|
1437
|
-
}
|
|
1438
|
-
}
|
|
1439
|
-
return out
|
|
1440
|
-
}
|
|
1441
|
-
|
|
1442
|
-
/** Bounded BFS — shallowest match wins; several distinct values at that depth is ambiguous */
|
|
1443
|
-
function searchSchemaKey(root: Record<string, unknown>, key: string, maxDepth: number): SchemaMetaHit {
|
|
1444
|
-
let level = [root]
|
|
1445
|
-
for (let depth = 0; depth <= maxDepth && level.length > 0; depth++) {
|
|
1446
|
-
const matches: unknown[] = []
|
|
1447
|
-
const seen = new Set<string>()
|
|
1448
|
-
for (const node of level) {
|
|
1449
|
-
if (!Object.hasOwn(node, key) || node[key] === undefined) continue
|
|
1450
|
-
const fingerprint = JSON.stringify(node[key]) ?? "undefined"
|
|
1451
|
-
if (seen.has(fingerprint)) continue
|
|
1452
|
-
seen.add(fingerprint)
|
|
1453
|
-
matches.push(node[key])
|
|
1454
|
-
}
|
|
1455
|
-
if (matches.length === 1) return { found: true, value: matches[0] }
|
|
1456
|
-
if (matches.length > 1) return { found: "ambiguous", values: matches }
|
|
1457
|
-
const next: Record<string, unknown>[] = []
|
|
1458
|
-
for (const node of level) next.push(...schemaChildren(node))
|
|
1459
|
-
level = next
|
|
1460
|
-
}
|
|
1461
|
-
return { found: false }
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
|
-
const DEEP_SEARCH_MAX_DEPTH = 6
|
|
1465
|
-
|
|
1466
|
-
/**
|
|
1467
|
-
* Resolves one schema-metadata key against one schema source. `"root"` reads the schema root,
|
|
1468
|
-
* seeing through one level of `items` so a bare `array<Entity>` list output still finds the
|
|
1469
|
-
* descriptor stamped on the item. `"deep"` walks the schema for envelope shapes.
|
|
1470
|
-
*/
|
|
1471
|
-
function makeSchemaMetaLookup(handler: RouteHandler): SchemaMetaLookup {
|
|
1472
|
-
const cache = new Map<string, Record<string, unknown> | undefined>()
|
|
1473
|
-
|
|
1474
|
-
const rootOf = (source: MetaSpecSchemaSource): Record<string, unknown> | undefined => {
|
|
1475
|
-
if (cache.has(source)) return cache.get(source)
|
|
1476
|
-
let schema: StandardSchemaLike | null = null
|
|
1477
|
-
let io: "input" | "output" = "output"
|
|
1478
|
-
if (source === "output") {
|
|
1479
|
-
schema = primaryOutputSchema(handler)
|
|
1480
|
-
} else {
|
|
1481
|
-
io = "input"
|
|
1482
|
-
const key = source.slice("input.".length) as keyof InputSchemasDef
|
|
1483
|
-
const entry = handler.iv?.[key]
|
|
1484
|
-
schema = entry ? unwrapEntry(entry as InputSchemaEntry) : null
|
|
1485
|
-
}
|
|
1486
|
-
let root: Record<string, unknown> | undefined
|
|
1487
|
-
if (schema) {
|
|
1488
|
-
const converted = asJsonSchema(schema, io)
|
|
1489
|
-
root = converted && typeof converted === "object" ? converted : undefined
|
|
1490
|
-
}
|
|
1491
|
-
cache.set(source, root)
|
|
1492
|
-
return root
|
|
1493
|
-
}
|
|
1494
|
-
|
|
1495
|
-
return (source, key, search) => {
|
|
1496
|
-
const root = rootOf(source)
|
|
1497
|
-
if (!root) return { found: false }
|
|
1498
|
-
if (search === "deep") return searchSchemaKey(root, key, DEEP_SEARCH_MAX_DEPTH)
|
|
1499
|
-
if (Object.hasOwn(root, key) && root[key] !== undefined) return { found: true, value: root[key] }
|
|
1500
|
-
/* a list output is `array<Entity>` — the descriptor sits on the item */
|
|
1501
|
-
if (root.type === "array") {
|
|
1502
|
-
const items = root.items
|
|
1503
|
-
if (items !== null && typeof items === "object" && !Array.isArray(items)) {
|
|
1504
|
-
const itemRoot = items as Record<string, unknown>
|
|
1505
|
-
if (Object.hasOwn(itemRoot, key) && itemRoot[key] !== undefined) {
|
|
1506
|
-
return { found: true, value: itemRoot[key] }
|
|
1507
|
-
}
|
|
1508
|
-
}
|
|
1509
|
-
}
|
|
1510
|
-
return { found: false }
|
|
1511
|
-
}
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
1245
|
export async function generateOpenApi<TEnv, TCtx, TMeta = unknown>(
|
|
1515
1246
|
app: Honey<TEnv, TCtx, unknown, TMeta, unknown, string, string>,
|
|
1516
1247
|
options: {
|
|
@@ -1528,347 +1259,14 @@ export async function generateOpenApi<TEnv, TCtx, TMeta = unknown>(
|
|
|
1528
1259
|
},
|
|
1529
1260
|
): Promise<OpenApiSpec> {
|
|
1530
1261
|
await Promise.all([loadToJSONSchema(), loadEffectJsonSchema()])
|
|
1531
|
-
const
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
const rawErrorSchema = getErrorSchema(app)
|
|
1539
|
-
const baseErrorJsonSchema = rawErrorSchema
|
|
1540
|
-
? (schemaToJsonSchema(rawErrorSchema) as Record<string, unknown>)
|
|
1541
|
-
: DEFAULT_ERROR_JSON_SCHEMA
|
|
1542
|
-
|
|
1543
|
-
/* resolve custom error formatter schema (for merging into custom schema errors) */
|
|
1544
|
-
const rawCustomErrorSchema = getCustomErrorSchema(app)
|
|
1545
|
-
const customErrorAddsSchema = rawCustomErrorSchema
|
|
1546
|
-
? (schemaToJsonSchema(rawCustomErrorSchema) as Record<string, unknown>)
|
|
1547
|
-
: null
|
|
1548
|
-
|
|
1549
|
-
const tree = (app as unknown as { _tree: TreeNode })._tree
|
|
1550
|
-
const collected: CollectedRoute[] = []
|
|
1551
|
-
walkTree(tree, "", collected)
|
|
1552
|
-
|
|
1553
|
-
const routeFilter = options.filterRoutes
|
|
1554
|
-
const paths: Record<string, Record<string, Record<string, unknown>>> = {}
|
|
1555
|
-
/* operations as emitted, for whole-document checks that need to see siblings */
|
|
1556
|
-
const emitted: InvalidateCheckOperation[] = []
|
|
1557
|
-
|
|
1558
|
-
for (const { handler, method, path } of collected) {
|
|
1559
|
-
if (routeFilter) {
|
|
1560
|
-
const meta = handler.mt as TMeta
|
|
1561
|
-
if (!routeFilter({ meta, method, path })) continue
|
|
1562
|
-
}
|
|
1563
|
-
const oaPath = toOpenApiPath(path)
|
|
1564
|
-
if (paths[oaPath] === undefined) {
|
|
1565
|
-
paths[oaPath] = {}
|
|
1566
|
-
}
|
|
1567
|
-
|
|
1568
|
-
const methodKey = method.toLowerCase()
|
|
1569
|
-
const operation: Record<string, unknown> = {}
|
|
1570
|
-
const responses: Record<string, unknown> = {}
|
|
1571
|
-
const parameters: Array<Record<string, unknown>> = []
|
|
1572
|
-
|
|
1573
|
-
/* meta + schema meta → openApi operation fields, per the declared policy */
|
|
1574
|
-
applyMetaSpec({
|
|
1575
|
-
collector,
|
|
1576
|
-
filter: profileFilter,
|
|
1577
|
-
kind: "http",
|
|
1578
|
-
meta: handler.mt as Record<string, unknown> | null,
|
|
1579
|
-
method,
|
|
1580
|
-
operation,
|
|
1581
|
-
path,
|
|
1582
|
-
profile: profileName,
|
|
1583
|
-
schemaMeta: metaSpec.needsSchemas ? makeSchemaMetaLookup(handler) : undefined,
|
|
1584
|
-
spec: metaSpec,
|
|
1585
|
-
})
|
|
1586
|
-
|
|
1587
|
-
/* path params */
|
|
1588
|
-
const params = extractParams(path)
|
|
1589
|
-
for (const name of params) {
|
|
1590
|
-
const isOptional = path.includes(`:${name}?`)
|
|
1591
|
-
parameters.push({
|
|
1592
|
-
in: "path",
|
|
1593
|
-
name,
|
|
1594
|
-
required: !isOptional,
|
|
1595
|
-
schema: { type: "string" },
|
|
1596
|
-
})
|
|
1597
|
-
}
|
|
1598
|
-
|
|
1599
|
-
/* input → requestBody / parameters */
|
|
1600
|
-
if (handler.iv) {
|
|
1601
|
-
for (const [source, entry] of Object.entries(handler.iv)) {
|
|
1602
|
-
if (entry === undefined) continue
|
|
1603
|
-
/* readableStream() is a runtime tag; the document describes the inner schema */
|
|
1604
|
-
const unwrapped = unwrapEntry(entry as InputSchemaEntry)
|
|
1605
|
-
const jsonSchema = asJsonSchema(unwrapped, "input")
|
|
1606
|
-
|
|
1607
|
-
if (source === "json" || source === "form") {
|
|
1608
|
-
const schema = stripInternalProps(jsonSchema)
|
|
1609
|
-
const content: Record<string, { schema: Record<string, unknown> }> = {}
|
|
1610
|
-
if (source === "form") {
|
|
1611
|
-
/* z.file() is format:binary — illegal on urlencoded; oat only builds FormData for multipart/* */
|
|
1612
|
-
if (formSchemaHasBinaryPart(schema)) {
|
|
1613
|
-
content["multipart/form-data"] = { schema }
|
|
1614
|
-
}
|
|
1615
|
-
content["application/x-www-form-urlencoded"] = { schema }
|
|
1616
|
-
} else {
|
|
1617
|
-
content["application/json"] = { schema }
|
|
1618
|
-
}
|
|
1619
|
-
operation.requestBody = {
|
|
1620
|
-
content,
|
|
1621
|
-
required: true,
|
|
1622
|
-
}
|
|
1623
|
-
} else if (source === "search" || source === "headers" || source === "cookies") {
|
|
1624
|
-
let location: "cookie" | "header" | "query" = "cookie"
|
|
1625
|
-
if (source === "search") location = "query"
|
|
1626
|
-
else if (source === "headers") location = "header"
|
|
1627
|
-
/* decompose object schema properties into individual parameters */
|
|
1628
|
-
const props = jsonSchema.properties as Record<string, unknown> | undefined
|
|
1629
|
-
const required = (jsonSchema.required as string[]) ?? []
|
|
1630
|
-
if (props) {
|
|
1631
|
-
for (const [propName, propSchema] of Object.entries(props)) {
|
|
1632
|
-
if ((propSchema as Record<string, unknown>)?.["x-internal"] === true) continue
|
|
1633
|
-
parameters.push({
|
|
1634
|
-
in: location,
|
|
1635
|
-
name: propName,
|
|
1636
|
-
required: required.includes(propName),
|
|
1637
|
-
schema: propSchema,
|
|
1638
|
-
})
|
|
1639
|
-
}
|
|
1640
|
-
}
|
|
1641
|
-
} else if (source === "params") {
|
|
1642
|
-
/* override path param schemas with actual schema types */
|
|
1643
|
-
const props = jsonSchema.properties as Record<string, unknown> | undefined
|
|
1644
|
-
if (props) {
|
|
1645
|
-
for (const param of parameters) {
|
|
1646
|
-
const propSchema = props[param.name as string]
|
|
1647
|
-
if (propSchema && param.in === "path") {
|
|
1648
|
-
param.schema = propSchema
|
|
1649
|
-
}
|
|
1650
|
-
}
|
|
1651
|
-
}
|
|
1652
|
-
}
|
|
1653
|
-
}
|
|
1654
|
-
}
|
|
1655
|
-
|
|
1656
|
-
if (parameters.length > 0) {
|
|
1657
|
-
operation.parameters = parameters
|
|
1658
|
-
}
|
|
1659
|
-
|
|
1660
|
-
/* output → responses */
|
|
1661
|
-
if (handler.os) {
|
|
1662
|
-
for (const [contentType, schemas] of Object.entries(handler.os)) {
|
|
1663
|
-
if (schemas === undefined) continue
|
|
1664
|
-
if (contentType === "redirect") {
|
|
1665
|
-
for (const statusKey of Object.keys(schemas)) {
|
|
1666
|
-
const statusCode = statusKeyToCode[statusKey as keyof typeof statusKeyToCode]
|
|
1667
|
-
if (statusCode) {
|
|
1668
|
-
responses[String(statusCode)] = {
|
|
1669
|
-
description: statusKey.replace(/_/g, " "),
|
|
1670
|
-
headers: {
|
|
1671
|
-
Location: {
|
|
1672
|
-
description: "Redirect target URL",
|
|
1673
|
-
schema: { format: "uri", type: "string" },
|
|
1674
|
-
},
|
|
1675
|
-
},
|
|
1676
|
-
}
|
|
1677
|
-
}
|
|
1678
|
-
}
|
|
1679
|
-
continue
|
|
1680
|
-
}
|
|
1681
|
-
for (const [statusKey, schema] of Object.entries(schemas)) {
|
|
1682
|
-
if (schema === undefined) continue
|
|
1683
|
-
const statusCode = statusKeyToCode[statusKey as keyof typeof statusKeyToCode]
|
|
1684
|
-
if (statusCode) {
|
|
1685
|
-
responses[String(statusCode)] = {
|
|
1686
|
-
content: {
|
|
1687
|
-
[contentType]: {
|
|
1688
|
-
schema: asJsonSchema(schema as StandardSchemaLike | Record<string, unknown>),
|
|
1689
|
-
},
|
|
1690
|
-
},
|
|
1691
|
-
description: statusKey.replace(/_/g, " "),
|
|
1692
|
-
}
|
|
1693
|
-
}
|
|
1694
|
-
}
|
|
1695
|
-
}
|
|
1696
|
-
}
|
|
1697
|
-
|
|
1698
|
-
/* error responses from declared error keys */
|
|
1699
|
-
if (handler.ek.size > 0) {
|
|
1700
|
-
type ErrorEntry = { key: string; schema: Record<string, unknown> | null }
|
|
1701
|
-
const byStatus = new Map<number, ErrorEntry[]>()
|
|
1702
|
-
for (const ek of handler.ek) {
|
|
1703
|
-
const info = resolveErrorInfo(ek, factory)
|
|
1704
|
-
if (info.status > 0) {
|
|
1705
|
-
let entries = byStatus.get(info.status)
|
|
1706
|
-
if (!entries) {
|
|
1707
|
-
entries = []
|
|
1708
|
-
byStatus.set(info.status, entries)
|
|
1709
|
-
}
|
|
1710
|
-
/* check if this error has a custom schema */
|
|
1711
|
-
const meta = errorMeta?.[ek]
|
|
1712
|
-
let customSchema: Record<string, unknown> | null = null
|
|
1713
|
-
if (meta?.schema) {
|
|
1714
|
-
const converted = schemaToJsonSchema(meta.schema as StandardSchemaLike)
|
|
1715
|
-
if (converted) {
|
|
1716
|
-
customSchema = customErrorAddsSchema
|
|
1717
|
-
? { allOf: [converted, customErrorAddsSchema] }
|
|
1718
|
-
: (converted as Record<string, unknown>)
|
|
1719
|
-
}
|
|
1720
|
-
}
|
|
1721
|
-
entries.push({ key: ek, schema: customSchema })
|
|
1722
|
-
}
|
|
1723
|
-
}
|
|
1724
|
-
for (const [status, entries] of byStatus) {
|
|
1725
|
-
if (responses[String(status)] === undefined) {
|
|
1726
|
-
const standardKeys = entries.filter((e) => !e.schema).map((e) => e.key)
|
|
1727
|
-
const customSchemas = entries.filter((e) => e.schema).map((e) => e.schema as Record<string, unknown>)
|
|
1728
|
-
|
|
1729
|
-
let schema: Record<string, unknown>
|
|
1730
|
-
|
|
1731
|
-
if (customSchemas.length === 0) {
|
|
1732
|
-
/* all standard errors at this status — use base error schema with constrained enums */
|
|
1733
|
-
schema = cloneJson(baseErrorJsonSchema)
|
|
1734
|
-
const props = schema.properties as Record<string, unknown> | undefined
|
|
1735
|
-
if (props?.error_key) {
|
|
1736
|
-
props.error_key = { enum: standardKeys.sort(), type: "string" }
|
|
1737
|
-
}
|
|
1738
|
-
if (props?.status) {
|
|
1739
|
-
props.status = { enum: [status], type: "integer" }
|
|
1740
|
-
}
|
|
1741
|
-
} else if (standardKeys.length === 0 && customSchemas.length === 1) {
|
|
1742
|
-
/* single custom schema error — use it directly */
|
|
1743
|
-
schema = customSchemas[0]
|
|
1744
|
-
} else {
|
|
1745
|
-
/* mixed standard + custom, or multiple custom — use oneOf */
|
|
1746
|
-
const schemas: Record<string, unknown>[] = []
|
|
1747
|
-
if (standardKeys.length > 0) {
|
|
1748
|
-
const stdSchema = cloneJson(baseErrorJsonSchema)
|
|
1749
|
-
const props = stdSchema.properties as Record<string, unknown> | undefined
|
|
1750
|
-
if (props?.error_key) {
|
|
1751
|
-
props.error_key = { enum: standardKeys.sort(), type: "string" }
|
|
1752
|
-
}
|
|
1753
|
-
if (props?.status) {
|
|
1754
|
-
props.status = { enum: [status], type: "integer" }
|
|
1755
|
-
}
|
|
1756
|
-
schemas.push(stdSchema)
|
|
1757
|
-
}
|
|
1758
|
-
schemas.push(...customSchemas)
|
|
1759
|
-
schema = { oneOf: schemas }
|
|
1760
|
-
}
|
|
1761
|
-
|
|
1762
|
-
responses[String(status)] = {
|
|
1763
|
-
content: {
|
|
1764
|
-
"application/json": { schema },
|
|
1765
|
-
},
|
|
1766
|
-
description: entries
|
|
1767
|
-
.map((e) => e.key)
|
|
1768
|
-
.sort()
|
|
1769
|
-
.join(", "),
|
|
1770
|
-
}
|
|
1771
|
-
}
|
|
1772
|
-
}
|
|
1773
|
-
}
|
|
1774
|
-
|
|
1775
|
-
if (Object.keys(responses).length === 0) {
|
|
1776
|
-
responses["200"] = { description: "Success" }
|
|
1777
|
-
}
|
|
1778
|
-
operation.responses = responses
|
|
1779
|
-
|
|
1780
|
-
paths[oaPath][methodKey] = operation
|
|
1781
|
-
emitted.push({
|
|
1782
|
-
meta: handler.mt as Record<string, unknown> | null,
|
|
1783
|
-
method: methodKey,
|
|
1784
|
-
operation,
|
|
1785
|
-
path: oaPath,
|
|
1786
|
-
})
|
|
1787
|
-
}
|
|
1788
|
-
|
|
1789
|
-
/* WS routes */
|
|
1790
|
-
const wsRoutes: CollectedWSRoute[] = []
|
|
1791
|
-
walkWSRoutes(tree, "", wsRoutes)
|
|
1792
|
-
|
|
1793
|
-
for (const { handler, path } of wsRoutes) {
|
|
1794
|
-
const oaPath = toOpenApiPath(path)
|
|
1795
|
-
if (paths[oaPath] === undefined) {
|
|
1796
|
-
paths[oaPath] = {}
|
|
1797
|
-
}
|
|
1798
|
-
|
|
1799
|
-
const operation: Record<string, unknown> = { "x-websocket": true }
|
|
1800
|
-
const parameters: Array<Record<string, unknown>> = []
|
|
1801
|
-
|
|
1802
|
-
applyMetaSpec({
|
|
1803
|
-
collector,
|
|
1804
|
-
filter: profileFilter,
|
|
1805
|
-
kind: "ws",
|
|
1806
|
-
meta: handler.mt as Record<string, unknown> | null,
|
|
1807
|
-
method: "WS",
|
|
1808
|
-
operation,
|
|
1809
|
-
path,
|
|
1810
|
-
profile: profileName,
|
|
1811
|
-
schemaMeta: metaSpec.needsSchemas ? makeSchemaMetaLookup(handler as unknown as RouteHandler) : undefined,
|
|
1812
|
-
spec: metaSpec,
|
|
1813
|
-
})
|
|
1814
|
-
|
|
1815
|
-
/* path params */
|
|
1816
|
-
const wsParams = extractParams(path)
|
|
1817
|
-
for (const name of wsParams) {
|
|
1818
|
-
parameters.push({
|
|
1819
|
-
in: "path",
|
|
1820
|
-
name,
|
|
1821
|
-
required: true,
|
|
1822
|
-
schema: { type: "string" },
|
|
1823
|
-
})
|
|
1824
|
-
}
|
|
1825
|
-
|
|
1826
|
-
/* query params from input schemas — use same conversion as HTTP routes */
|
|
1827
|
-
if (handler.iv?.search) {
|
|
1828
|
-
const searchEntry = handler.iv.search
|
|
1829
|
-
const searchSchema =
|
|
1830
|
-
"_tag" in searchEntry
|
|
1831
|
-
? (searchEntry as { schema: StandardSchemaLike }).schema
|
|
1832
|
-
: (searchEntry as StandardSchemaLike)
|
|
1833
|
-
const jsonSchema = asJsonSchema(searchSchema as StandardSchemaLike | Record<string, unknown>, "input")
|
|
1834
|
-
if (jsonSchema && typeof jsonSchema === "object") {
|
|
1835
|
-
const props = (jsonSchema as Record<string, unknown>).properties as Record<string, unknown> | undefined
|
|
1836
|
-
const required = new Set(((jsonSchema as Record<string, unknown>).required ?? []) as string[])
|
|
1837
|
-
if (props) {
|
|
1838
|
-
for (const [name, schema] of Object.entries(props)) {
|
|
1839
|
-
parameters.push({
|
|
1840
|
-
in: "query",
|
|
1841
|
-
name,
|
|
1842
|
-
required: required.has(name),
|
|
1843
|
-
schema,
|
|
1844
|
-
})
|
|
1845
|
-
}
|
|
1846
|
-
}
|
|
1847
|
-
}
|
|
1848
|
-
}
|
|
1849
|
-
|
|
1850
|
-
if (parameters.length > 0) operation.parameters = parameters
|
|
1851
|
-
operation.responses = { "101": { description: "WebSocket upgrade" } }
|
|
1852
|
-
|
|
1853
|
-
paths[oaPath].get = operation
|
|
1854
|
-
}
|
|
1855
|
-
|
|
1856
|
-
collector.flush()
|
|
1857
|
-
/* after the policy is known good — a broken policy makes this report meaningless */
|
|
1858
|
-
reportMissingInvalidate(emitted, options.invalidate)
|
|
1859
|
-
|
|
1860
|
-
const result: OpenApiSpec = {
|
|
1861
|
-
info: options.info,
|
|
1862
|
-
openapi: "3.1.0",
|
|
1863
|
-
paths,
|
|
1864
|
-
}
|
|
1865
|
-
if (options.securitySchemes) {
|
|
1866
|
-
result.components = {
|
|
1867
|
-
...result.components,
|
|
1868
|
-
securitySchemes: options.securitySchemes,
|
|
1869
|
-
}
|
|
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)
|
|
1870
1269
|
}
|
|
1871
|
-
return deduplicateSchemas(result)
|
|
1872
1270
|
}
|
|
1873
1271
|
|
|
1874
1272
|
export function extractSchemas<TEnv, TCtx>(
|
|
@@ -1998,98 +1396,129 @@ function collectRoutesForTree(node: TreeNode, currentPath: string, routes: Route
|
|
|
1998
1396
|
}
|
|
1999
1397
|
}
|
|
2000
1398
|
|
|
2001
|
-
|
|
2002
|
-
|
|
1399
|
+
type TreeBuild = {
|
|
1400
|
+
children: Record<string, TreeBuild>
|
|
1401
|
+
dynamic: { child: TreeBuild; name: string } | null
|
|
1402
|
+
handlers: Array<{ handler: string; method: string }>
|
|
1403
|
+
wildcard: {
|
|
1404
|
+
handlers: Array<{ handler: string; method: string }>
|
|
1405
|
+
name: string
|
|
1406
|
+
} | null
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function createTreeBuild(): TreeBuild {
|
|
1410
|
+
return { children: {}, dynamic: null, handlers: [], wildcard: null }
|
|
2003
1411
|
}
|
|
2004
1412
|
|
|
2005
|
-
function
|
|
2006
|
-
const
|
|
2007
|
-
const
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
1413
|
+
function serializeNode(node: TreeBuild): string {
|
|
1414
|
+
const staticEntries = Object.entries(node.children)
|
|
1415
|
+
const sExpr =
|
|
1416
|
+
staticEntries.length > 0
|
|
1417
|
+
? `S({ ${staticEntries.map(([k, v]) => `${JSON.stringify(k)}: ${serializeNode(v)}`).join(", ")} })`
|
|
1418
|
+
: "E"
|
|
1419
|
+
|
|
1420
|
+
const dExpr = node.dynamic
|
|
1421
|
+
? `{ n: ${JSON.stringify(node.dynamic.name)}, c: ${serializeNode(node.dynamic.child)} }`
|
|
1422
|
+
: "null"
|
|
1423
|
+
|
|
1424
|
+
const wExpr = node.wildcard
|
|
1425
|
+
? `{ n: ${JSON.stringify(node.wildcard.name)}, m: { ${node.wildcard.handlers.map((h) => `${h.method}: ${h.handler}`).join(", ")} } }`
|
|
1426
|
+
: "null"
|
|
1427
|
+
|
|
1428
|
+
const mExpr =
|
|
1429
|
+
node.handlers.length > 0 ? `{ ${node.handlers.map((h) => `${h.method}: ${h.handler}`).join(", ")} }` : "null"
|
|
1430
|
+
|
|
1431
|
+
const args = [sExpr]
|
|
1432
|
+
if (mExpr !== "null" || dExpr !== "null" || wExpr !== "null") {
|
|
1433
|
+
args.push(mExpr)
|
|
1434
|
+
if (dExpr !== "null" || wExpr !== "null") {
|
|
1435
|
+
args.push(dExpr)
|
|
1436
|
+
if (wExpr !== "null") args.push(wExpr)
|
|
2016
1437
|
}
|
|
2017
|
-
index.push(id)
|
|
2018
1438
|
}
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
const
|
|
2039
|
-
const
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
if (!rows.some((row) => row !== null)) return {}
|
|
2047
|
-
const names: string[] = []
|
|
2048
|
-
const seen = new Set<string>()
|
|
2049
|
-
for (const row of rows) {
|
|
2050
|
-
if (row === null) continue
|
|
2051
|
-
for (const key of Object.keys(row)) {
|
|
2052
|
-
if (seen.has(key)) continue
|
|
2053
|
-
if (/^\d+$/.test(key)) return { m: encodePackedColumn(rows) }
|
|
2054
|
-
seen.add(key)
|
|
2055
|
-
names.push(key)
|
|
2056
|
-
}
|
|
1439
|
+
return `N(${args.join(", ")})`
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
function nodeHasStatic(node: TreeBuild): boolean {
|
|
1443
|
+
if (Object.keys(node.children).length > 0) return true
|
|
1444
|
+
if (node.dynamic !== null) return nodeHasStatic(node.dynamic.child)
|
|
1445
|
+
return false
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
function selectorOf(route: RouteConfig): string {
|
|
1449
|
+
return `${route.method} ${route.path}`
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
function mapKeyExpr(intern: InternPool, selector: string): string {
|
|
1453
|
+
const id = intern.id(selector)
|
|
1454
|
+
return id !== undefined ? `[${id}]` : JSON.stringify(selector)
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
function buildInternPool(routes: RouteConfig[]): InternPool {
|
|
1458
|
+
const intern = new InternPool()
|
|
1459
|
+
for (const route of routes) {
|
|
1460
|
+
intern.count(route.boundaryErrorKey)
|
|
1461
|
+
intern.count(route.errorKeys)
|
|
1462
|
+
intern.count(route.inputSchemas)
|
|
1463
|
+
intern.count(route.outputSchemas)
|
|
1464
|
+
intern.count(route.meta)
|
|
1465
|
+
intern.count(selectorOf(route))
|
|
2057
1466
|
}
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
return out
|
|
2064
|
-
})
|
|
2065
|
-
return pickSmallerJson({ m: encodePackedColumn(packed), u: names }, { m: encodePackedColumn(rows) })
|
|
2066
|
-
}
|
|
2067
|
-
|
|
2068
|
-
function packRouteTable(routes: RouteConfig[]): PackedRouteTable {
|
|
2069
|
-
const table: PackedRouteTable = { s: routes.map(selectorOf) }
|
|
2070
|
-
const b = routes.map((r) => r.boundaryErrorKey)
|
|
2071
|
-
const e = routes.map((r) => r.errorKeys)
|
|
2072
|
-
const i = routes.map((r) => packInputSchemas(r.inputSchemas))
|
|
2073
|
-
const o = routes.map((r) => packOutputSchemas(r.outputSchemas))
|
|
2074
|
-
const m = routes.map((r) => r.meta)
|
|
2075
|
-
if (b.some((x) => x !== null)) {
|
|
2076
|
-
const first = b[0]
|
|
2077
|
-
if (first !== null && b.every((x) => x === first)) table.b = first
|
|
2078
|
-
else table.b = b
|
|
1467
|
+
for (const route of routes) {
|
|
1468
|
+
intern.force(route.inputSchemas, "I")
|
|
1469
|
+
intern.force(route.outputSchemas, "O")
|
|
1470
|
+
intern.force(route.meta, "M")
|
|
1471
|
+
intern.force(selectorOf(route), "P")
|
|
2079
1472
|
}
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
if (o.some((x) => x !== null)) table.o = encodePackedColumn(o)
|
|
2083
|
-
Object.assign(table, encodeMetaColumn(m))
|
|
2084
|
-
return table
|
|
1473
|
+
intern.seal()
|
|
1474
|
+
return intern
|
|
2085
1475
|
}
|
|
2086
1476
|
|
|
2087
|
-
function
|
|
2088
|
-
const
|
|
2089
|
-
|
|
2090
|
-
|
|
1477
|
+
function insertHandler(rootBuild: TreeBuild, route: RouteConfig, hName: string): void {
|
|
1478
|
+
const segments = route.path.split("/").filter((s) => s.length > 0)
|
|
1479
|
+
let node = rootBuild
|
|
1480
|
+
|
|
1481
|
+
for (let i = 0; i < segments.length; i++) {
|
|
1482
|
+
const seg = segments[i]
|
|
1483
|
+
if (seg.startsWith("*")) {
|
|
1484
|
+
const name = seg.length > 1 ? seg.slice(1) : "*"
|
|
1485
|
+
if (node.wildcard === null) {
|
|
1486
|
+
node.wildcard = { handlers: [], name }
|
|
1487
|
+
}
|
|
1488
|
+
node.wildcard.handlers.push({ handler: hName, method: route.method })
|
|
1489
|
+
return
|
|
1490
|
+
}
|
|
1491
|
+
if (seg.startsWith(":")) {
|
|
1492
|
+
const name = seg.endsWith("?") ? seg.slice(1, -1) : seg.slice(1)
|
|
1493
|
+
if (node.dynamic === null) {
|
|
1494
|
+
node.dynamic = { child: createTreeBuild(), name }
|
|
1495
|
+
}
|
|
1496
|
+
if (i === segments.length - 1) {
|
|
1497
|
+
node.dynamic.child.handlers.push({
|
|
1498
|
+
handler: hName,
|
|
1499
|
+
method: route.method,
|
|
1500
|
+
})
|
|
1501
|
+
} else {
|
|
1502
|
+
node = node.dynamic.child
|
|
1503
|
+
}
|
|
1504
|
+
continue
|
|
1505
|
+
}
|
|
1506
|
+
if (node.children[seg] === undefined) {
|
|
1507
|
+
node.children[seg] = createTreeBuild()
|
|
1508
|
+
}
|
|
1509
|
+
if (i === segments.length - 1) {
|
|
1510
|
+
node.children[seg].handlers.push({
|
|
1511
|
+
handler: hName,
|
|
1512
|
+
method: route.method,
|
|
1513
|
+
})
|
|
1514
|
+
} else {
|
|
1515
|
+
node = node.children[seg]
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
if (segments.length === 0) {
|
|
1520
|
+
node.handlers.push({ handler: hName, method: route.method })
|
|
2091
1521
|
}
|
|
2092
|
-
return json
|
|
2093
1522
|
}
|
|
2094
1523
|
|
|
2095
1524
|
function collectMetaShape(collected: CollectedRoute[]): {
|
|
@@ -2125,50 +1554,103 @@ function emitMetaShapeType(shape: ReturnType<typeof collectMetaShape>): string {
|
|
|
2125
1554
|
}
|
|
2126
1555
|
|
|
2127
1556
|
function emitRouteTree(routes: RouteConfig[], collected: CollectedRoute[] | null): string {
|
|
1557
|
+
const intern = buildInternPool(routes)
|
|
2128
1558
|
const metaShape = collected !== null ? collectMetaShape(collected) : null
|
|
2129
1559
|
const hasMetaShape = metaShape !== null && metaShape.allKeys.size > 0
|
|
2130
|
-
const selectors = routes.map(selectorOf)
|
|
2131
|
-
const table = packRouteTable(routes)
|
|
2132
1560
|
const lines: string[] = []
|
|
1561
|
+
lines.push('import type { TreeNode, RouteHandler, RouteTree } from "@lovrozagar/honey/tree"')
|
|
1562
|
+
lines.push("")
|
|
1563
|
+
lines.push("const E = Object.create(null) as Record<string, TreeNode>")
|
|
2133
1564
|
lines.push(
|
|
2134
|
-
'
|
|
1565
|
+
'function N(s: Record<string, TreeNode>, m: Record<string, RouteHandler> | null = null, d: TreeNode["d"] = null, w: { n: string; m: Record<string, RouteHandler> } | null = null): TreeNode {',
|
|
2135
1566
|
)
|
|
1567
|
+
lines.push('\treturn { s, d, w: w as TreeNode["w"], m: m as TreeNode["m"], ws: null }')
|
|
1568
|
+
lines.push("}")
|
|
1569
|
+
|
|
1570
|
+
const rootBuild = createTreeBuild()
|
|
1571
|
+
for (let i = 0; i < routes.length; i++) {
|
|
1572
|
+
insertHandler(rootBuild, routes[i], `H${i}`)
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
if (nodeHasStatic(rootBuild)) {
|
|
1576
|
+
lines.push("function S(o: Record<string, TreeNode>): Record<string, TreeNode> {")
|
|
1577
|
+
lines.push("\treturn Object.assign(Object.create(null), o) as Record<string, TreeNode>")
|
|
1578
|
+
lines.push("}")
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
if (routes.length > 0) {
|
|
1582
|
+
lines.push('const FN = null as unknown as RouteHandler["fn"]')
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
const consts = intern.emitConstLines()
|
|
1586
|
+
if (consts.length > 0) {
|
|
1587
|
+
lines.push("")
|
|
1588
|
+
for (const line of consts) {
|
|
1589
|
+
if (hasMetaShape && /^const M\d+ = /.test(line)) lines.push(`${line} as unknown as MetaShape`)
|
|
1590
|
+
else lines.push(line)
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
if (routes.length > 0) {
|
|
1595
|
+
lines.push("")
|
|
1596
|
+
/* unique handler per route — sharing H objects would cross-wire .routeTree() patches */
|
|
1597
|
+
for (let i = 0; i < routes.length; i++) {
|
|
1598
|
+
const route = routes[i]
|
|
1599
|
+
const bekExpr = route.boundaryErrorKey !== null ? intern.expr(route.boundaryErrorKey) : "null"
|
|
1600
|
+
const ekSet = route.errorKeys.length > 0 ? `new Set(${intern.expr(route.errorKeys)})` : "new Set()"
|
|
1601
|
+
const ivExpr = route.inputSchemas !== null ? intern.expr(route.inputSchemas) : "null"
|
|
1602
|
+
const osExpr = route.outputSchemas !== null ? intern.expr(route.outputSchemas) : "null"
|
|
1603
|
+
const mtExpr = route.meta !== null ? intern.expr(route.meta) : "null"
|
|
1604
|
+
lines.push(
|
|
1605
|
+
`const H${i}: RouteHandler = { bek: ${bekExpr}, fn: FN, mw: [], ek: ${ekSet}, iv: ${ivExpr}, os: ${osExpr}, mt: ${mtExpr} }`,
|
|
1606
|
+
)
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
|
|
2136
1610
|
lines.push("")
|
|
2137
|
-
lines.push(`
|
|
2138
|
-
lines.push(
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
lines.push(
|
|
2142
|
-
|
|
1611
|
+
lines.push(`export const tree: TreeNode = ${serializeNode(rootBuild)}`)
|
|
1612
|
+
lines.push("")
|
|
1613
|
+
|
|
1614
|
+
const handlerMapEntries = routes.map((route, index) => `\t${mapKeyExpr(intern, selectorOf(route))}: H${index}`)
|
|
1615
|
+
lines.push(`export const handlers: Record<string, RouteHandler> = {\n${handlerMapEntries.join(",\n")}\n}`)
|
|
1616
|
+
lines.push("")
|
|
1617
|
+
|
|
1618
|
+
let code = lines.join("\n")
|
|
2143
1619
|
if (collected === null) return code
|
|
2144
1620
|
|
|
1621
|
+
const metaEntries: string[] = []
|
|
1622
|
+
for (const { handler, method, path } of collected) {
|
|
1623
|
+
if (handler.mt === null || Object.keys(handler.mt).length === 0) continue
|
|
1624
|
+
const selector = `${method} ${path}`
|
|
1625
|
+
metaEntries.push(`\t${mapKeyExpr(intern, selector)}: ${intern.expr(handler.mt)}`)
|
|
1626
|
+
}
|
|
1627
|
+
|
|
2145
1628
|
if (hasMetaShape && metaShape !== null) {
|
|
2146
1629
|
code += `\n${emitMetaShapeType(metaShape)}\n`
|
|
2147
1630
|
}
|
|
2148
1631
|
|
|
2149
|
-
const
|
|
1632
|
+
const selectors = new Set<string>()
|
|
2150
1633
|
for (const { handler, method, path } of collected) {
|
|
2151
1634
|
if (handler._skip || isMetaInternal(handler)) continue
|
|
2152
|
-
|
|
1635
|
+
selectors.add(`${method} ${path}`)
|
|
2153
1636
|
}
|
|
2154
|
-
if (
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
})
|
|
2162
|
-
code += `export type RouteSelector = ${parts.join(" | ")}\n`
|
|
2163
|
-
}
|
|
1637
|
+
if (selectors.size > 0) {
|
|
1638
|
+
const sorted = [...selectors].sort()
|
|
1639
|
+
const parts = sorted.map((s) => {
|
|
1640
|
+
const id = intern.id(s)
|
|
1641
|
+
return id !== undefined ? `typeof ${id}` : JSON.stringify(s)
|
|
1642
|
+
})
|
|
1643
|
+
code += `\nexport type RouteSelector = ${parts.join(" | ")}\n`
|
|
2164
1644
|
}
|
|
2165
1645
|
|
|
2166
|
-
|
|
2167
|
-
if (hasMeta) {
|
|
1646
|
+
if (metaEntries.length > 0) {
|
|
2168
1647
|
const metaType = hasMetaShape ? "Record<string, MetaShape>" : "Record<string, Record<string, unknown>>"
|
|
2169
|
-
code += `export const meta: ${metaType} =
|
|
1648
|
+
code += `export const meta: ${metaType} = {\n${metaEntries.join(",\n")}\n}\n`
|
|
2170
1649
|
}
|
|
2171
1650
|
|
|
1651
|
+
const hasMetaExport = metaEntries.length > 0
|
|
1652
|
+
code += `export const routeTree: RouteTree = { root: tree, ${hasMetaExport ? "meta, " : "meta: {}, "}handlers }\n`
|
|
1653
|
+
|
|
2172
1654
|
return code
|
|
2173
1655
|
}
|
|
2174
1656
|
|