2026-03-10 00:00:00 -07:00
|
|
|
/**
|
|
|
|
|
* 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<RouteCheckResult> {
|
|
|
|
|
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.`,
|
2026-04-30 12:47:40 -07:00
|
|
|
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;',
|
2026-03-10 00:00:00 -07:00
|
|
|
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<RouteCheckResult> {
|
|
|
|
|
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<RouteCheckResult[]> {
|
|
|
|
|
const results: RouteCheckResult[] = [];
|
|
|
|
|
|
|
|
|
|
results.push(checkAppFile(options));
|
|
|
|
|
results.push(await checkRouteDiscovery(options));
|
|
|
|
|
results.push(await checkSwaggerRegistration(options));
|
|
|
|
|
|
|
|
|
|
return results;
|
|
|
|
|
}
|