199 lines
6.0 KiB
TypeScript
199 lines
6.0 KiB
TypeScript
/**
|
|
* Route discovery from a Fastify instance.
|
|
* Pure functions, no side effects.
|
|
*
|
|
* Fastify 5 removed the public `routes` array. We capture routes via the `onRoute`
|
|
* hook during plugin registration and store them in a WeakMap keyed by the instance.
|
|
*/
|
|
import { extractContract } from './contract.js'
|
|
import type { RouteContract } from '../types.js'
|
|
|
|
interface CapturedRoute {
|
|
method: string
|
|
url: string
|
|
schema?: Record<string, unknown>
|
|
prefix?: string
|
|
sourceFile?: string
|
|
}
|
|
|
|
export interface DiscoveryResult {
|
|
routes: RouteContract[]
|
|
source: 'captured' | 'legacy-routes-array' | 'print-routes' | 'none'
|
|
hasSchemaMetadata: boolean
|
|
warnings: string[]
|
|
}
|
|
|
|
// WeakMap to store captured routes per Fastify instance (no memory leaks)
|
|
const capturedRoutes = new WeakMap<object, CapturedRoute[]>()
|
|
|
|
/**
|
|
* Extract the source file that registered a route from a stack trace.
|
|
* Finds the first frame outside node_modules and APOPHIS internal directories.
|
|
*/
|
|
function extractSourceFile(stack: string): string | undefined {
|
|
const lines = stack.split('\n')
|
|
for (let i = 3; i < lines.length; i++) {
|
|
const line = lines[i]
|
|
if (!line) continue
|
|
const match = line.match(/\((.*?):\d+:\d+\)/) ?? line.match(/at\s+(.*?):\d+:\d+/)
|
|
if (!match || !match[1]) continue
|
|
const filePath = match[1]
|
|
if (!filePath.includes('node_modules') && !filePath.includes('/apophis/') && !filePath.includes('\\apophis\\')) {
|
|
return filePath
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Capture a route for discovery.
|
|
* Called from the plugin's `onRoute` hook.
|
|
*/
|
|
export const captureRoute = (
|
|
instance: object,
|
|
route: CapturedRoute
|
|
): void => {
|
|
route.sourceFile = extractSourceFile(new Error().stack ?? '')
|
|
const existing = capturedRoutes.get(instance) ?? []
|
|
existing.push(route)
|
|
capturedRoutes.set(instance, existing)
|
|
}
|
|
|
|
/**
|
|
* Fallback route discovery for Fastify 5 when routes were registered before
|
|
* the APOPHIS plugin (e.g., external apps loaded by CLI).
|
|
* Parses printRoutes() output to discover route paths and methods.
|
|
*
|
|
* printRoutes() output looks like:
|
|
* └── /
|
|
* ├── users (GET, HEAD)
|
|
* └── items/
|
|
* └── :id (POST)
|
|
*/
|
|
function parsePrintRoutesOutput(output: string): Array<{ method: string; path: string }> {
|
|
const routes: Array<{ method: string; path: string }> = []
|
|
const lines = output.split('\n')
|
|
|
|
const depthPrefixes: string[] = []
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trimStart()
|
|
if (!trimmed) continue
|
|
|
|
const indent = line.length - line.trimStart().length
|
|
const depth = Math.floor(indent / 2)
|
|
|
|
depthPrefixes.length = depth
|
|
|
|
const branchMatch = trimmed.match(/^(├──|└──)\s+(.+)$/)
|
|
if (!branchMatch) continue
|
|
|
|
let segment = branchMatch[2]!
|
|
|
|
const isIntermediate = segment.endsWith('/')
|
|
if (isIntermediate) {
|
|
segment = segment.slice(0, -1)
|
|
}
|
|
|
|
const methodMatch = segment.match(/^(.+?)\s+\(([^)]+)\)$/)
|
|
if (methodMatch) {
|
|
const rawPath = methodMatch[1]!
|
|
const methodList = methodMatch[2]!.split(',').map(m => m.trim())
|
|
const fullPath = '/' + [...depthPrefixes, rawPath].filter(Boolean).join('/')
|
|
|
|
for (const method of methodList) {
|
|
if (method === 'HEAD') continue
|
|
routes.push({ method, path: fullPath })
|
|
}
|
|
} else {
|
|
depthPrefixes.push(segment)
|
|
}
|
|
}
|
|
|
|
return routes
|
|
}
|
|
|
|
/**
|
|
* Fallback route discovery for Fastify 5 when onRoute hook wasn't installed
|
|
* before route registration.
|
|
*/
|
|
function discoverRoutesFallback(
|
|
instance: {
|
|
hasRoute?: (opts: { method: string; url: string }) => boolean
|
|
printRoutes?: () => string
|
|
}
|
|
): DiscoveryResult {
|
|
let routes: RouteContract[] = []
|
|
let source: DiscoveryResult['source'] = 'none'
|
|
let warnings: string[] = []
|
|
|
|
if (typeof instance.printRoutes === 'function') {
|
|
const output = instance.printRoutes()
|
|
const parsed = parsePrintRoutesOutput(output)
|
|
if (parsed.length > 0) {
|
|
routes = parsed.map(r => extractContract(r.path, r.method, undefined))
|
|
source = 'print-routes'
|
|
warnings.push(
|
|
'Route schemas were not available during discovery. ' +
|
|
'Behavioral contracts (x-ensures, x-requires, x-outbound, x-variants, x-timeout) ' +
|
|
'will not be detected. Register APOPHIS or install route discovery before defining routes, ' +
|
|
'or use createFastify() from @apophis/fastify.'
|
|
)
|
|
}
|
|
}
|
|
|
|
return { routes, source, hasSchemaMetadata: false, warnings }
|
|
}
|
|
|
|
export interface DiscoverRouteDetailsInput {
|
|
routes?: Array<{ method: string; url: string; schema?: Record<string, unknown> }>
|
|
hasRoute?: (opts: { method: string; url: string }) => boolean
|
|
printRoutes?: () => string
|
|
}
|
|
|
|
/**
|
|
* Discover routes from a Fastify instance with full metadata about discovery quality.
|
|
*
|
|
* Use this function when you need to know whether routes were discovered
|
|
* with full schema metadata (captured / legacy-routes-array) or as
|
|
* schema-less fallback paths (print-routes / none).
|
|
*/
|
|
export const discoverRouteDetails = (instance: DiscoverRouteDetailsInput): DiscoveryResult => {
|
|
const captured = capturedRoutes.get(instance)
|
|
if (captured && captured.length > 0) {
|
|
return {
|
|
routes: captured.map((route) => {
|
|
const contract = extractContract(route.url, route.method, route.schema)
|
|
contract.sourceFile = route.sourceFile
|
|
return contract
|
|
}),
|
|
source: 'captured',
|
|
hasSchemaMetadata: true,
|
|
warnings: [],
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(instance.routes) && instance.routes.length > 0) {
|
|
return {
|
|
routes: instance.routes.map((route) =>
|
|
extractContract(route.url, route.method, route.schema)
|
|
),
|
|
source: 'legacy-routes-array',
|
|
hasSchemaMetadata: true,
|
|
warnings: [],
|
|
}
|
|
}
|
|
|
|
return discoverRoutesFallback(instance)
|
|
}
|
|
|
|
/**
|
|
* Discover routes from a Fastify instance.
|
|
*
|
|
* First checks captured routes (from onRoute hook), then falls back to
|
|
* the legacy `routes` array for Fastify 4 compatibility.
|
|
*/
|
|
export const discoverRoutes = (instance: DiscoverRouteDetailsInput): RouteContract[] => {
|
|
return discoverRouteDetails(instance).routes
|
|
}
|