/** * Plugin Contract System * * Enables Fastify plugins to declare APOPHIS contracts that are * automatically merged into route contracts at test time. */ import type { PluginContractSpec, ComposedContract } from '../plugin/contracts.js' // ============================================================================ import type { RouteContract } from '../types.js' // Pattern Matching // ============================================================================ function matchPattern(pattern: string, path: string): boolean { // Exact match if (pattern === path) return true // Double-wildcard: matches everything if (pattern === '**') return true // Wildcard match: '/api/**' matches '/api/users', '/api/users/:id' if (pattern.endsWith('/**')) { const prefix = pattern.slice(0, -3) if (prefix === '') return true // '/**' matches everything return path.startsWith(prefix) } // Prefix match: '/api/*' matches '/api/users' but not '/api/users/:id' if (pattern.endsWith('/*')) { const prefix = pattern.slice(0, -2) if (!path.startsWith(prefix)) return false const remainder = path.slice(prefix.length) // remainder should be empty or a single segment without nested slashes if (remainder === '') return true // Remove leading slash and check no more slashes const trimmed = remainder.startsWith('/') ? remainder.slice(1) : remainder return !trimmed.includes('/') } return false } // ============================================================================ // Plugin Contract Registry // ============================================================================ export class PluginContractRegistry { private contracts = new Map() private availableExtensions = new Set() /** * Register a plugin's contract specification. * Idempotent: registering the same plugin twice updates the spec. */ register(name: string, spec: PluginContractSpec): void { this.contracts.set(name, spec) } /** * Register available Apophis extensions. * Called by the extension registry when extensions are added. */ registerAvailableExtension(name: string): void { this.availableExtensions.add(name) } /** * Check if all required extensions for a plugin are available. */ checkExtensions(spec: PluginContractSpec): { available: boolean; missing: string[] } { const missing: string[] = [] for (const ext of spec.extensions ?? []) { if (ext.required !== false && !this.availableExtensions.has(ext.name)) { missing.push(ext.name) } } return { available: missing.length === 0, missing } } /** * Find all plugin contracts that apply to a given route. * Skips plugins whose required extensions are not available. */ findContractsForRoute(route: RouteContract): Array<{ plugin: string; spec: PluginContractSpec }> { const matches: Array<{ plugin: string; spec: PluginContractSpec }> = [] for (const [plugin, spec] of this.contracts) { if (!matchPattern(spec.appliesTo, route.path)) continue const extCheck = this.checkExtensions(spec) if (!extCheck.available) { console.warn( `Plugin '${plugin}' requires extensions [${extCheck.missing.join(', ')}] which are not registered. Skipping its contracts.` ) continue } matches.push({ plugin, spec }) } return matches } /** * Merge route contracts with applicable plugin contracts. */ composeContracts(route: RouteContract): ComposedContract { const pluginContracts = this.findContractsForRoute(route) const phases: ComposedContract['phases'] = {} // Route-level contracts go into 'route' phase phases.route = { requires: route.requires.map((f) => ({ formula: f, source: 'route' as const })), ensures: route.ensures.map((f) => ({ formula: f, source: 'route' as const })), } // Merge plugin contracts by phase for (const { plugin, spec } of pluginContracts) { for (const [phase, contracts] of Object.entries(spec.hooks)) { if (!phases[phase]) { phases[phase] = { requires: [], ensures: [] } } for (const req of contracts.requires ?? []) { phases[phase]!.requires.push({ formula: req, source: `plugin:${plugin}` as const, }) } for (const ens of contracts.ensures ?? []) { phases[phase]!.ensures.push({ formula: ens, source: `plugin:${plugin}` as const, }) } } } return { route, phases } } /** * Get all registered plugin names. */ getPluginNames(): string[] { return Array.from(this.contracts.keys()) } /** * Check if a plugin is registered. */ hasPlugin(name: string): boolean { return this.contracts.has(name) } /** * Get a plugin's spec. */ getPluginSpec(name: string): PluginContractSpec | undefined { return this.contracts.get(name) } /** * Get all available extension names. */ getAvailableExtensions(): string[] { return Array.from(this.availableExtensions) } } // ============================================================================ // Factory // ============================================================================ export function createPluginContractRegistry(): PluginContractRegistry { return new PluginContractRegistry() }