Files
apophis-fastify/src/domain/plugin-contracts.ts
T

190 lines
6.3 KiB
TypeScript

/**
* 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<string, PluginContractSpec>()
private availableExtensions = new Set<string>()
/**
* 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)
}
}
// ============================================================================
// Built-in Plugin Contracts
// ============================================================================
export const BUILTIN_PLUGIN_CONTRACTS: Record<string, PluginContractSpec> = {
'@fastify/auth': {
appliesTo: '**',
hooks: {
onRequest: {
requires: ['request_headers(this).authorization != null'],
},
},
},
'@fastify/compress': {
appliesTo: '**',
hooks: {
onSend: {
ensures: ['response_headers(this).content-encoding != null'],
},
},
},
'@fastify/cors': {
appliesTo: '**',
hooks: {
onRequest: {
ensures: ['response_headers(this).access-control-allow-origin != null'],
},
},
},
'@fastify/rate-limit': {
appliesTo: '**',
hooks: {
onRequest: {
ensures: [
'response_headers(this).x-ratelimit-limit != null',
'response_headers(this).x-ratelimit-remaining != null',
],
},
},
},
}
// ============================================================================
// Factory
// ============================================================================
export function createPluginContractRegistry(): PluginContractRegistry {
return new PluginContractRegistry()
}