190 lines
6.3 KiB
TypeScript
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()
|
|
} |