/** * S8: Doctor thread - Route discovery checks * * Checks: * - Can we discover routes from the Fastify app? * - Are routes properly registered with swagger? * - Is the app file loadable? */ import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface RouteCheckResult { name: string; status: 'pass' | 'fail' | 'warn'; message: string; detail?: string; remediation?: string; mode: 'all' | 'verify' | 'observe' | 'qualify'; } export interface RouteCheckOptions { cwd: string; configPath?: string; } // --------------------------------------------------------------------------- // App file detection // --------------------------------------------------------------------------- const APP_CANDIDATES = [ 'app.js', 'app.ts', 'server.js', 'server.ts', 'index.js', 'index.ts', 'src/app.js', 'src/app.ts', 'src/server.js', 'src/server.ts', 'src/index.js', 'src/index.ts', ]; /** * Find the Fastify app entrypoint file. */ function findAppFile(cwd: string): string | null { for (const candidate of APP_CANDIDATES) { const fullPath = resolve(cwd, candidate); if (existsSync(fullPath)) { return candidate; } } return null; } /** * Check if app file exists and is readable. */ export function checkAppFile(options: RouteCheckOptions): RouteCheckResult { const appFile = findAppFile(options.cwd); if (!appFile) { return { name: 'app-file', status: 'warn', message: 'No Fastify app file found.', detail: `Searched for: ${APP_CANDIDATES.join(', ')}. ` + 'APOPHIS needs an app.js or similar to discover routes.', remediation: 'Create an app.js or server.js that exports a Fastify instance.', mode: 'all', }; } return { name: 'app-file', status: 'pass', message: `Found Fastify app: ${appFile}`, mode: 'all', }; } // --------------------------------------------------------------------------- // Route discovery check // --------------------------------------------------------------------------- /** * Attempt to load the app and discover routes. */ export async function checkRouteDiscovery(options: RouteCheckOptions): Promise { const appFile = findAppFile(options.cwd); if (!appFile) { return { name: 'route-discovery', status: 'warn', message: 'Skipping route discovery (no app file found).', mode: 'all', }; } try { const appPath = resolve(options.cwd, appFile); const appModule = await import(appPath); const app = appModule.default || appModule; // Check if it looks like a Fastify instance if (!app || typeof app !== 'object') { return { name: 'route-discovery', status: 'fail', message: `App file ${appFile} does not export a valid object.`, detail: 'Ensure the app file exports a Fastify instance or a factory function.', remediation: 'Export your Fastify instance: export default app; or export const createApp = () => app; or module.exports = app;', mode: 'all', }; } // Try to register APOPHIS plugin for route capture // Skip if already registered to avoid "decorator already added" errors const isAlreadyRegistered = app.hasDecorator && typeof app.hasDecorator === 'function' && app.hasDecorator('apophis'); if (!isAlreadyRegistered) { try { const apophisPlugin = (await import('../../../../index.js')).default; if (typeof apophisPlugin === 'function' && typeof app.register === 'function') { await app.register(apophisPlugin, { runtime: 'off' }); } } catch (err) { const errMessage = err instanceof Error ? err.message : String(err); // If decorator already added, the plugin is pre-registered — that's fine if (errMessage.includes("decorator 'apophis' has already been added")) { // Plugin is already registered, proceed with discovery } // Otherwise, plugin registration is optional for discovery } } // Try to ready the app so routes are registered if (typeof app.ready === 'function') { await app.ready(); } // Check for routes let routeCount = 0; // Fastify 5+ routes access if (app.routes && typeof app.routes === 'function') { const routes = app.routes(); routeCount = Array.isArray(routes) ? routes.length : 0; } // Fallback: check if we can get routes via inject or other methods if (routeCount === 0 && app.hasRoute) { // We can't enumerate, but we can at least verify the app is functional routeCount = -1; // Unknown but app seems functional } if (routeCount === 0) { return { name: 'route-discovery', status: 'warn', message: `App loaded from ${appFile} but no routes were discovered.`, detail: 'Ensure routes are registered before exporting the app. ' + 'APOPHIS discovers routes via the onRoute hook.', remediation: 'Register routes before exporting the app, or ensure the APOPHIS plugin is registered.', mode: 'all', }; } if (routeCount < 0) { return { name: 'route-discovery', status: 'pass', message: `App loaded from ${appFile}. Route enumeration not available (app is functional).`, detail: 'Route count could not be determined, but the app appears to be a valid Fastify instance.', mode: 'all', }; } return { name: 'route-discovery', status: 'pass', message: `Discovered ${routeCount} route(s) from ${appFile}.`, mode: 'all', }; } catch (error) { const message = error instanceof Error ? error.message : String(error); // If the error is a module not found, treat as warn (dependencies may not be installed in test env) if (message.includes('Cannot find module') || message.includes('Cannot resolve')) { return { name: 'route-discovery', status: 'warn', message: `Could not load app from ${appFile}: ${message}`, detail: 'Dependencies may not be installed. Run npm install to resolve.', remediation: 'Run npm install to install missing dependencies.', mode: 'all', }; } return { name: 'route-discovery', status: 'fail', message: `Failed to load app from ${appFile}: ${message}`, detail: 'Check that the app file exports a valid Fastify instance and all imports resolve.', remediation: 'Verify all imports in your app file are correct and the file exports a Fastify instance.', mode: 'all', }; } } // --------------------------------------------------------------------------- // Swagger registration check // --------------------------------------------------------------------------- /** * Check if @fastify/swagger is registered in the app. */ export async function checkSwaggerRegistration(options: RouteCheckOptions): Promise { const appFile = findAppFile(options.cwd); if (!appFile) { return { name: 'swagger-registration', status: 'warn', message: 'Skipping swagger check (no app file found).', mode: 'all', }; } try { const appPath = resolve(options.cwd, appFile); const content = (await import('node:fs')).readFileSync(appPath, 'utf-8'); if (content.includes('@fastify/swagger') || content.includes('fastify-swagger')) { return { name: 'swagger-registration', status: 'pass', message: `@fastify/swagger appears to be imported in ${appFile}.`, mode: 'all', }; } return { name: 'swagger-registration', status: 'warn', message: `@fastify/swagger not found in ${appFile}.`, detail: 'APOPHIS requires @fastify/swagger for route discovery. ' + 'Register it with: await app.register(import("@fastify/swagger"), { openapi: { info: { title: "API", version: "1.0.0" } } });', remediation: 'npm install @fastify/swagger@^9.0.0 and register it in your app file.', mode: 'all', }; } catch { return { name: 'swagger-registration', status: 'warn', message: `Could not read ${appFile} to check swagger registration.`, mode: 'all', }; } } // --------------------------------------------------------------------------- // Main route check runner // --------------------------------------------------------------------------- /** * Run all route discovery checks. */ export async function runRouteChecks(options: RouteCheckOptions): Promise { const results: RouteCheckResult[] = []; results.push(checkAppFile(options)); results.push(await checkRouteDiscovery(options)); results.push(await checkSwaggerRegistration(options)); return results; }