Files
apophis-fastify/src/domain/discovery.ts
T

175 lines
5.2 KiB
TypeScript
Raw Normal View History

/**
* 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
}
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[]>()
/**
* Capture a route for discovery.
* Called from the plugin's `onRoute` hook.
*/
export const captureRoute = (
instance: object,
route: CapturedRoute
): void => {
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) =>
extractContract(route.url, route.method, route.schema)
),
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
}