Files
apophis-fastify/src/domain/contract.ts
T

117 lines
4.6 KiB
TypeScript
Raw Normal View History

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
}