From 889d9a6477242a07ba5b947d01522b25f23a98a6 Mon Sep 17 00:00:00 2001 From: sid sri Date: Mon, 3 Aug 2026 17:24:34 +0530 Subject: [PATCH] feat(graph): add FastAPI route resolver --- src/graph/__tests__/fixtures/fastapi-app.py | 32 ++++ src/graph/__tests__/resolver-fastapi.test.ts | 156 +++++++++++++++++++ src/graph/resolution/frameworks/fastapi.ts | 133 ++++++++++++++++ src/graph/resolution/frameworks/index.ts | 4 +- 4 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 src/graph/__tests__/fixtures/fastapi-app.py create mode 100644 src/graph/__tests__/resolver-fastapi.test.ts create mode 100644 src/graph/resolution/frameworks/fastapi.ts diff --git a/src/graph/__tests__/fixtures/fastapi-app.py b/src/graph/__tests__/fixtures/fastapi-app.py new file mode 100644 index 0000000..a62584a --- /dev/null +++ b/src/graph/__tests__/fixtures/fastapi-app.py @@ -0,0 +1,32 @@ +from fastapi import APIRouter, FastAPI + +app = FastAPI() +router = APIRouter() + + +@app.get("/health") +def health(): + return {"status": "ok"} + + +@app.post("/users/{user_id}") +@app.patch("/users/{user_id}") +async def update_user(user_id: str): + return {"user_id": user_id} + + +@router.put("/users/{user_id}") +def replace_user(user_id: str): + return {"user_id": user_id} + + +@router.options("/users") +@router.head("/users") +def inspect_users(): + return None + + +class AdminRoutes: + @router.delete("/admin/{user_id}") + def delete_user(self, user_id: str): + return {"deleted": user_id} diff --git a/src/graph/__tests__/resolver-fastapi.test.ts b/src/graph/__tests__/resolver-fastapi.test.ts new file mode 100644 index 0000000..58c25b1 --- /dev/null +++ b/src/graph/__tests__/resolver-fastapi.test.ts @@ -0,0 +1,156 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { beforeAll, describe, expect, it } from "vitest"; +import { extractFile, loadGrammars } from "../extraction/index.js"; +import { generateNodeId } from "../extraction/node-id.js"; +import { fastAPIResolver } from "../resolution/frameworks/fastapi.js"; +import { FRAMEWORK_RESOLVERS } from "../resolution/frameworks/index.js"; +import type { GraphNode } from "../types.js"; +import type { ResolutionContext } from "../resolution/types.js"; + +const FILE_PATH = "src/fastapi-app.py"; +const fixturePath = join(dirname(fileURLToPath(import.meta.url)), "fixtures", "fastapi-app.py"); +const source = readFileSync(fixturePath, "utf-8"); + +describe("FastAPI framework resolver", () => { + let pythonNodes: GraphNode[]; + + beforeAll(async () => { + await loadGrammars(["python"]); + pythonNodes = extractFile(FILE_PATH, source, "python")!.nodes.map((node) => ({ + ...node, + updatedAt: 0, + })); + }); + + it.each([ + ["pyproject project dependencies", { "pyproject.toml": "[project]\ndependencies = [\"fastapi>=0.115\"]\n" }], + ["Poetry dependencies", { "pyproject.toml": "[tool.poetry.dependencies]\nfastapi = \"^0.115\"\n" }], + ["requirements file", { "requirements-dev.txt": "pytest==8.0\nfastapi[standard]>=0.115\n" }], + ])("detects FastAPI from %s", (_name, files) => { + expect(fastAPIResolver.detect(fakeContext([], files))).toBe(true); + }); + + it("does not detect similarly named or unrelated dependencies", () => { + const context = fakeContext([], { + "pyproject.toml": "[project]\ndependencies = [\"flask\"]\n", + "requirements.txt": "fastapi-utils==0.8.0\n", + }); + expect(fastAPIResolver.detect(context)).toBe(false); + }); + + it("extracts stable route nodes and endpoint references", () => { + const result = fastAPIResolver.extract!(FILE_PATH, source); + const expectedRoutes = [ + "GET /health", + "POST /users/{user_id}", + "PATCH /users/{user_id}", + "PUT /users/{user_id}", + "OPTIONS /users", + "HEAD /users", + "DELETE /admin/{user_id}", + ]; + + expect(result.nodes.map((node) => node.name)).toEqual(expectedRoutes); + for (const node of result.nodes) { + expect(node).toMatchObject({ kind: "route", language: "python", filePath: FILE_PATH }); + expect(node.id).toBe(generateNodeId(FILE_PATH, "route", node.name)); + } + expect(result.references.map((ref) => [ref.referenceName, ref.referenceKind])).toEqual([ + ["health", "function_ref"], + ["update_user", "function_ref"], + ["update_user", "function_ref"], + ["replace_user", "function_ref"], + ["inspect_users", "function_ref"], + ["inspect_users", "function_ref"], + ["delete_user", "function_ref"], + ]); + }); + + it("recognizes custom instance names and skips dynamic or unrelated routes", () => { + const customSource = [ + "api = FastAPI()", + "client = HttpClient()", + "route_path = '/dynamic'", + "@api.get('/ready')", + "def ready(): pass", + "@client.get('/external')", + "def external(): pass", + "@api.get(route_path)", + "def dynamic(): pass", + "", + ].join("\n"); + + const result = fastAPIResolver.extract!("src/custom.py", customSource); + expect(result.nodes).toMatchObject([{ kind: "route", name: "GET /ready" }]); + expect(result.references).toMatchObject([{ referenceName: "ready" }]); + }); + + it("resolves unambiguous same-file functions and methods", () => { + const result = fastAPIResolver.extract!(FILE_PATH, source); + const context = fakeContext(pythonNodes); + + for (const endpoint of ["health", "update_user", "delete_user"]) { + const ref = result.references.find((entry) => entry.referenceName === endpoint)!; + const target = pythonNodes.find((node) => node.name === endpoint)!; + expect(fastAPIResolver.resolve(ref, context)).toMatchObject({ + targetNodeId: target.id, + confidence: 1, + resolvedBy: "framework", + }); + } + }); + + it("leaves missing, cross-file-only, and ambiguous endpoints unresolved", () => { + const ref = fastAPIResolver.extract!(FILE_PATH, source).references[0]!; + const crossFile = node("function:cross-file", "health", "src/other.py"); + expect(fastAPIResolver.resolve(ref, fakeContext([crossFile]))).toBeNull(); + expect(fastAPIResolver.resolve(ref, fakeContext([]))).toBeNull(); + + const sameFile = node("function:same-file", "health", FILE_PATH); + const duplicate = node("method:duplicate", "health", FILE_PATH, "method"); + expect(fastAPIResolver.resolve(ref, fakeContext([sameFile, duplicate]))).toBeNull(); + }); + + it("ignores non-Python files and is registered", () => { + expect(fastAPIResolver.extract!("src/app.ts", "@app.get('/health')\ndef health(): pass")) + .toEqual({ nodes: [], references: [] }); + expect(FRAMEWORK_RESOLVERS).toContain(fastAPIResolver); + }); +}); + +function node( + id: string, + name: string, + filePath: string, + kind: "function" | "method" = "function", +): GraphNode { + return { + id, + kind, + name, + qualifiedName: name, + filePath, + language: "python", + startLine: 1, + endLine: 2, + startColumn: 0, + endColumn: 0, + updatedAt: 0, + }; +} + +function fakeContext(nodes: GraphNode[], files: Record = {}): ResolutionContext { + return { + getNodesInFile: (path) => nodes.filter((entry) => entry.filePath === path), + getNodesByName: (name) => nodes.filter((entry) => entry.name === name), + getNodesByQualifiedName: (name) => nodes.filter((entry) => entry.qualifiedName === name), + getNodesByKind: (kind) => nodes.filter((entry) => entry.kind === kind), + getNodeById: (id) => nodes.find((entry) => entry.id === id) ?? null, + fileExists: (path) => path in files, + readFile: (path) => files[path] ?? null, + getProjectRoot: () => "/repo", + getAllFiles: () => Object.keys(files), + }; +} diff --git a/src/graph/resolution/frameworks/fastapi.ts b/src/graph/resolution/frameworks/fastapi.ts new file mode 100644 index 0000000..b68bb4c --- /dev/null +++ b/src/graph/resolution/frameworks/fastapi.ts @@ -0,0 +1,133 @@ +import { generateNodeId } from "../../extraction/node-id.js"; +import type { GraphNode } from "../../types.js"; +import type { + FrameworkExtractionResult, + FrameworkResolver, + ResolvedRef, + UnresolvedRef, +} from "../types.js"; + +const FRAMEWORK_INSTANCE = /^\s*([A-Za-z_]\w*)\s*=\s*(?:FastAPI|APIRouter)\s*\(/gm; +const ROUTE_DECORATOR = /^(\s*)@([A-Za-z_]\w*)\.(get|post|put|patch|delete|options|head)\s*\(\s*(["'])([^"'\\]*)\4(?:\s*,.*)?\)\s*(?:#.*)?$/; +const ENDPOINT = /^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/; +const PYPROJECT_DEPENDENCY = /(?:^\s*fastapi\s*=|["']fastapi(?:\[[^\]]+\])?(?:\s*(?:[<>=!~]=?|@)[^"']*)?["'])/im; +const REQUIREMENTS_DEPENDENCY = /^\s*fastapi(?:\[[^\]]+\])?(?:\s*(?:[<>=!~]=?|@)[^;#\s]+)?(?:\s*;[^#]+)?\s*(?:#.*)?$/im; + +interface PendingRoute { + method: string; + path: string; + line: number; + startColumn: number; + endColumn: number; +} + +export const fastAPIResolver: FrameworkResolver = { + name: "fastapi", + languages: ["python"], + detect(context) { + return context.getAllFiles().some((filePath) => { + const normalizedPath = filePath.replace(/\\/g, "/"); + const content = context.readFile(filePath); + if (!content) return false; + + if (/(^|\/)pyproject\.toml$/i.test(normalizedPath)) { + return PYPROJECT_DEPENDENCY.test(content); + } + if (/(^|\/)requirements(?:[-_.][^/]*)?\.(?:txt|in)$/i.test(normalizedPath)) { + return REQUIREMENTS_DEPENDENCY.test(content); + } + return false; + }); + }, + claimsReference: (name) => /^[A-Za-z_]\w*$/.test(name), + extract(filePath, content): FrameworkExtractionResult { + if (!filePath.toLowerCase().endsWith(".py")) { + return { nodes: [], references: [] }; + } + + const nodes: GraphNode[] = []; + const references: UnresolvedRef[] = []; + const pendingRoutes: PendingRoute[] = []; + const routeReceivers = new Set( + [...content.matchAll(FRAMEWORK_INSTANCE)].map((match) => match[1]!), + ); + const lines = content.split(/\r?\n/); + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const line = lines[lineIndex]!; + const decorator = ROUTE_DECORATOR.exec(line); + if (decorator && routeReceivers.has(decorator[2]!)) { + pendingRoutes.push({ + method: decorator[3]!.toUpperCase(), + path: decorator[5]!, + line: lineIndex, + startColumn: decorator[1]!.length, + endColumn: line.length, + }); + continue; + } + + if (pendingRoutes.length === 0) continue; + if (/^\s*@/.test(line)) continue; + + const endpoint = ENDPOINT.exec(line); + if (endpoint) { + addRoutes(filePath, endpoint[1]!, pendingRoutes, nodes, references); + } + pendingRoutes.length = 0; + } + + return { nodes, references }; + }, + resolve(ref, context): ResolvedRef | null { + if (ref.referenceKind !== "function_ref") return null; + const candidates = context.getNodesInFile(ref.filePath).filter((node) => ( + (node.kind === "function" || node.kind === "method") + && node.name === ref.referenceName + )); + if (candidates.length !== 1) return null; + + return { + original: ref, + targetNodeId: candidates[0]!.id, + confidence: 1, + resolvedBy: "framework", + }; + }, +}; + +function addRoutes( + filePath: string, + endpoint: string, + routes: PendingRoute[], + nodes: GraphNode[], + references: UnresolvedRef[], +): void { + for (const route of routes) { + const name = `${route.method} ${route.path}`; + const id = generateNodeId(filePath, "route", name); + nodes.push({ + id, + kind: "route", + name, + qualifiedName: name, + filePath, + language: "python", + startLine: route.line + 1, + endLine: route.line + 1, + startColumn: route.startColumn, + endColumn: route.endColumn, + isExported: false, + updatedAt: 0, + }); + references.push({ + fromNodeId: id, + referenceName: endpoint, + referenceKind: "function_ref", + filePath, + language: "python", + line: route.line, + column: route.startColumn, + }); + } +} diff --git a/src/graph/resolution/frameworks/index.ts b/src/graph/resolution/frameworks/index.ts index 2949603..414fe90 100644 --- a/src/graph/resolution/frameworks/index.ts +++ b/src/graph/resolution/frameworks/index.ts @@ -1,6 +1,8 @@ import { expressResolver } from "./express.js"; +import { fastAPIResolver } from "./fastapi.js"; import type { FrameworkResolver } from "../types.js"; /** Reference registry. Community resolvers add one entry here. */ -export const FRAMEWORK_RESOLVERS: readonly FrameworkResolver[] = [expressResolver]; +export const FRAMEWORK_RESOLVERS: readonly FrameworkResolver[] = [expressResolver, fastAPIResolver]; export { expressResolver } from "./express.js"; +export { fastAPIResolver } from "./fastapi.js";