chore: crush git history - reborn from consolidation on 2026-03-10
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
Reference in New Issue
Block a user