117 lines
4.6 KiB
TypeScript
117 lines
4.6 KiB
TypeScript
|
|
import { inferCategory } from './category.js'
|
||
|
|
import { inferContractsFromRouteSchema } from './schema-to-contract.js'
|
||
|
|
// Reuse empty arrays to avoid allocation
|
||
|
|
import type { HttpMethod, OutboundBinding, RouteContract, ValidatedFormula } from '../types.js'
|
||
|
|
const EMPTY_REQUIRES: ValidatedFormula[] = []
|
||
|
|
const EMPTY_ENSURES: ValidatedFormula[] = []
|
||
|
|
const EMPTY_INVARIANTS: ValidatedFormula[] = []
|
||
|
|
// Two-level cache: WeakMap<schema, Map<"METHOD path", RouteContract>>
|
||
|
|
// Preserves automatic GC of schema objects while correctly caching per-route contracts
|
||
|
|
const contractCache = new WeakMap<Record<string, unknown>, Map<string, RouteContract>>()
|
||
|
|
export const extractContract = (
|
||
|
|
path: string,
|
||
|
|
method: string,
|
||
|
|
schema: Record<string, unknown> | undefined
|
||
|
|
): RouteContract => {
|
||
|
|
const s = schema ?? {}
|
||
|
|
// Fast path: two-level cache lookup (guard against null — WeakMap rejects null keys)
|
||
|
|
if (schema != null) {
|
||
|
|
let routeMap = contractCache.get(schema)
|
||
|
|
if (routeMap === undefined) {
|
||
|
|
routeMap = new Map()
|
||
|
|
contractCache.set(schema, routeMap)
|
||
|
|
}
|
||
|
|
const key = `${method.toUpperCase()} ${path}`
|
||
|
|
const cached = routeMap.get(key)
|
||
|
|
if (cached !== undefined) {
|
||
|
|
return cached
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const override = typeof s['x-category'] === 'string' ? s['x-category'] : undefined
|
||
|
|
const category = inferCategory(path, method, override)
|
||
|
|
// APOPHIS annotations may live on the top-level schema OR nested inside
|
||
|
|
// response.statusCode (e.g. schema.response[200]['x-ensures']).
|
||
|
|
// We merge both levels so contracts are never silently dropped.
|
||
|
|
const responseSchema = (s.response ?? {}) as Record<string, Record<string, unknown>>
|
||
|
|
const firstStatus = Object.values(responseSchema)[0] ?? {}
|
||
|
|
const topRequires = s['x-requires']
|
||
|
|
const nestedRequires = firstStatus['x-requires']
|
||
|
|
const requires = Array.isArray(topRequires) && topRequires.length > 0
|
||
|
|
? (topRequires as string[])
|
||
|
|
: Array.isArray(nestedRequires) && nestedRequires.length > 0
|
||
|
|
? (nestedRequires as string[])
|
||
|
|
: EMPTY_REQUIRES
|
||
|
|
const topEnsures = s['x-ensures']
|
||
|
|
const nestedEnsures = firstStatus['x-ensures']
|
||
|
|
const explicitEnsures = Array.isArray(topEnsures) && topEnsures.length > 0
|
||
|
|
? (topEnsures as string[])
|
||
|
|
: Array.isArray(nestedEnsures) && nestedEnsures.length > 0
|
||
|
|
? (nestedEnsures as string[])
|
||
|
|
: []
|
||
|
|
// Infer contracts from JSON Schema constraints (required, minimum, maximum, pattern, etc.)
|
||
|
|
// These supplement explicit x-ensures — never replace them.
|
||
|
|
const inferred = inferContractsFromRouteSchema(s)
|
||
|
|
const inferredSet = new Set(inferred)
|
||
|
|
const explicitSet = new Set(explicitEnsures)
|
||
|
|
// Deduplicate: don't add inferred formulas that the user already wrote explicitly
|
||
|
|
const additionalInferred = inferred.filter(f => !explicitSet.has(f))
|
||
|
|
// Merge: explicit first, then inferred
|
||
|
|
const ensures = explicitEnsures.length > 0 || additionalInferred.length > 0
|
||
|
|
? [...explicitEnsures, ...additionalInferred]
|
||
|
|
: EMPTY_ENSURES
|
||
|
|
const validateRuntime =
|
||
|
|
(s['x-validate-runtime'] !== false) &&
|
||
|
|
(firstStatus['x-validate-runtime'] !== false)
|
||
|
|
// Extract timeout from schema annotation
|
||
|
|
const timeoutValue = s['x-timeout'] ?? firstStatus['x-timeout']
|
||
|
|
const timeout = typeof timeoutValue === 'number' && timeoutValue > 0
|
||
|
|
? timeoutValue
|
||
|
|
: undefined
|
||
|
|
// Parse x-outbound annotation
|
||
|
|
const outboundRaw = s['x-outbound']
|
||
|
|
const outbound: OutboundBinding[] | undefined = Array.isArray(outboundRaw)
|
||
|
|
? (outboundRaw as OutboundBinding[])
|
||
|
|
: undefined
|
||
|
|
// Parse x-variants annotation
|
||
|
|
const variantsRaw = s['x-variants']
|
||
|
|
const variants = Array.isArray(variantsRaw)
|
||
|
|
? variantsRaw.map((v: unknown) => {
|
||
|
|
if (typeof v === 'string') {
|
||
|
|
return { name: v }
|
||
|
|
}
|
||
|
|
if (v !== null && typeof v === 'object') {
|
||
|
|
const vo = v as Record<string, unknown>
|
||
|
|
return {
|
||
|
|
name: String(vo.name || 'unnamed'),
|
||
|
|
headers: vo.headers as Record<string, string> | undefined,
|
||
|
|
when: vo.when as string | undefined,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return { name: String(v) }
|
||
|
|
})
|
||
|
|
: undefined
|
||
|
|
const contract: RouteContract = {
|
||
|
|
path,
|
||
|
|
method: method.toUpperCase() as HttpMethod,
|
||
|
|
category,
|
||
|
|
requires: requires as ValidatedFormula[],
|
||
|
|
ensures: ensures as ValidatedFormula[],
|
||
|
|
invariants: EMPTY_INVARIANTS,
|
||
|
|
regexPatterns: {},
|
||
|
|
validateRuntime,
|
||
|
|
schema: s,
|
||
|
|
timeout,
|
||
|
|
outbound,
|
||
|
|
variants,
|
||
|
|
}
|
||
|
|
if (schema !== undefined) {
|
||
|
|
const key = `${method.toUpperCase()} ${path}`
|
||
|
|
let routeMap = contractCache.get(schema)
|
||
|
|
if (routeMap === undefined) {
|
||
|
|
routeMap = new Map()
|
||
|
|
contractCache.set(schema, routeMap)
|
||
|
|
}
|
||
|
|
routeMap.set(key, contract)
|
||
|
|
}
|
||
|
|
return contract
|
||
|
|
}
|