Files
apophis-fastify/src/plugin/index.ts
T

151 lines
6.7 KiB
TypeScript
Raw Normal View History

/**
* APOPHIS Plugin v1.0 — Fastify plugin entry point.
* Thin wrapper: delegates all work to pure domain functions.
* Fastify plugin API is accidental; APOPHIS logic is essential.
*
* Architecture: Orchestrator — imports focused builders from submodules.
*/
import type { FastifyInstance } from 'fastify'
import { ScopeRegistry } from '../infrastructure/scope-registry.js'
import { CleanupManager } from '../infrastructure/cleanup-manager.js'
import { captureRoute } from '../domain/discovery.js'
import { registerValidationHooks, storeRouteContract } from '../infrastructure/hook-validator.js'
import { extractContract } from '../domain/contract.js'
import { createExtensionRegistry } from '../extension/registry.js'
import type { ApophisExtension } from '../extension/types.js'
import { createPluginContractRegistry } from '../domain/plugin-contracts.js'
import type { PluginContractRegistry } from '../domain/plugin-contracts.js'
import { OutboundContractRegistry } from '../domain/outbound-contracts.js'
import { createOutboundMockRuntime, type OutboundMockRuntime } from '../infrastructure/outbound-mock-runtime.js'
import { validateProductionSafety, assertTestEnv } from '../infrastructure/production-safety.js'
import {
registerSwagger,
buildSpec,
buildScenario,
buildCleanup,
buildContract,
buildStateful,
buildCheck,
} from './builders.js'
import type { ApophisDecorations, ApophisOptions, OutboundCallRecord, OutboundContractSpec, TestConfig } from '../types.js'
export const apophisPlugin = async (fastify: FastifyInstance, opts: ApophisOptions): Promise<void> => {
// Production safety: hard-fail if test-only options are present in production
validateProductionSafety(opts)
await registerSwagger(fastify, opts)
// Initialize registries before route capture so onRoute can validate formulas
// with any registered extension operation headers.
const pluginContractRegistry = createPluginContractRegistry()
const extensionRegistry = createExtensionRegistry()
extensionRegistry.setPluginContractRegistry(pluginContractRegistry)
// Initialize outbound contract registry
const outboundContractRegistry = new OutboundContractRegistry()
if (opts.outboundContracts) {
outboundContractRegistry.registerAll(opts.outboundContracts)
}
// Track active outbound mock runtime for imperative E2E tests
let activeMockRuntime: OutboundMockRuntime | undefined
// Capture routes as they're registered via Fastify's onRoute hook
fastify.addHook('onRoute', (routeOptions) => {
const method = Array.isArray(routeOptions.method)
? routeOptions.method.join(',')
: routeOptions.method
const schema = routeOptions.schema as Record<string, unknown> | undefined
const prefix = (routeOptions as unknown as Record<string, unknown>).prefix as string | undefined
const url = prefix && !routeOptions.url.startsWith(prefix)
? `${prefix}${routeOptions.url}`
: routeOptions.url
captureRoute(fastify, {
method,
url,
schema,
prefix,
})
// Extract contract and attach to route config for runtime validation hooks
const contract = extractContract(url, method, schema)
if (contract.validateRuntime && (contract.requires.length > 0 || contract.ensures.length > 0)) {
const config = routeOptions.config as Record<string, unknown> || {}
config.apophisContract = contract
routeOptions.config = config as typeof routeOptions.config
// Store for hook validator lookup (Fastify doesn't expose routes after ready)
const routeKey = `${contract.method} ${contract.path}`
storeRouteContract(routeKey, contract, extensionRegistry.getExtensionHeaders())
}
})
// Initialize scope registry with explicit config or empty
const scope = new ScopeRegistry(opts.scopes ?? {})
const cleanupManager = new CleanupManager(fastify, scope, opts.cleanup ?? false)
// Register user-provided plugin contracts
if (opts.pluginContracts) {
for (const [name, spec] of Object.entries(opts.pluginContracts)) {
pluginContractRegistry.register(name, spec)
}
}
if (opts.extensions) {
for (const ext of opts.extensions) {
extensionRegistry.register(ext as ApophisExtension)
}
}
const decorations: ApophisDecorations = {
scope,
contract: buildContract(fastify, scope, extensionRegistry, pluginContractRegistry, outboundContractRegistry),
stateful: buildStateful(fastify, scope, cleanupManager, extensionRegistry, pluginContractRegistry, outboundContractRegistry),
check: buildCheck(fastify, scope, extensionRegistry, pluginContractRegistry),
scenario: buildScenario(fastify, scope, extensionRegistry),
cleanup: buildCleanup(cleanupManager),
spec: buildSpec(fastify),
test: {
registerPluginContracts: (name: string, spec: import('../types.js').PluginContractSpec) => {
assertTestEnv('registerPluginContracts')
pluginContractRegistry.register(name, spec)
},
registerOutboundContracts: (contracts: Record<string, OutboundContractSpec>) => {
assertTestEnv('registerOutboundContracts')
outboundContractRegistry.registerAll(contracts)
},
enableOutboundMocks: (mockOpts?: TestConfig['outboundMocks']) => {
assertTestEnv('enableOutboundMocks')
if (activeMockRuntime) {
activeMockRuntime.restore()
}
// Disable case
if (mockOpts === false) {
activeMockRuntime = undefined
return
}
const contracts = outboundContractRegistry.resolve(
Array.from(outboundContractRegistry['contracts'].entries()).map(([name]) => name)
)
const mode = mockOpts?.mode ?? 'example'
const unmatched = mockOpts?.unmatched ?? 'error'
const seed = Math.floor(Math.random() * 0xFFFFFFFF)
activeMockRuntime = createOutboundMockRuntime({
contracts,
mode,
overrides: mockOpts?.overrides,
unmatched,
seed,
})
activeMockRuntime.install()
},
disableOutboundMocks: () => {
assertTestEnv('disableOutboundMocks')
if (activeMockRuntime) {
activeMockRuntime.restore()
activeMockRuntime = undefined
}
},
getOutboundCalls: (name?: string): ReadonlyArray<OutboundCallRecord> => {
assertTestEnv('getOutboundCalls')
return activeMockRuntime?.getCalls(name) ?? []
},
},
}
fastify.decorate('apophis', decorations)
// Runtime validation: never register hooks in production
const isProd = process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'prod'
if (opts.runtime && opts.runtime !== 'off' && !isProd) {
registerValidationHooks(fastify, { validateRuntime: true, runtimeLevel: opts.runtime })
}
}