Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/graph/__tests__/fixtures/fastapi-app.py
Original file line number Diff line number Diff line change
@@ -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}
156 changes: 156 additions & 0 deletions src/graph/__tests__/resolver-fastapi.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}): 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),
};
}
133 changes: 133 additions & 0 deletions src/graph/resolution/frameworks/fastapi.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
}
4 changes: 3 additions & 1 deletion src/graph/resolution/frameworks/index.ts
Original file line number Diff line number Diff line change
@@ -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";