From 6c18f577e89953827c7abdcda87cc3e2ee0c1044 Mon Sep 17 00:00:00 2001 From: Asish kumar Dalal Date: Mon, 18 May 2026 22:23:16 +0530 Subject: [PATCH] feat(python): support FastAPI APIRouter prefixes --- src/mappers/python.ts | 112 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 104 insertions(+), 8 deletions(-) diff --git a/src/mappers/python.ts b/src/mappers/python.ts index 730c70b..ab12251 100644 --- a/src/mappers/python.ts +++ b/src/mappers/python.ts @@ -58,7 +58,7 @@ const fastApiRouteDecoratorStartPattern = new RegExp( "u", ); const fastApiRouteDecoratorPattern = new RegExp( - `^\\s*@${fastApiRouteTargetPattern}\\.(${fastApiRouteMethods})\\((.*)\\)\\s*(?:#.*)?$`, + `^\\s*@(${fastApiRouteTargetPattern})\\.(${fastApiRouteMethods})\\((.*)\\)\\s*(?:#.*)?$`, "u", ); const projectMetadataFiles = [ @@ -980,7 +980,8 @@ function sourceLooksFastApi(source: string): boolean { function parseFastApiRoutes(filePath: string, source: string): FastApiRoute[] { const routes: FastApiRoute[] = []; - let pending: Array<{ routePath: string; methods: string[] }> = []; + const prefixes = parseFastApiRouterPrefixes(source); + let pending: Array<{ target: string; routePath: string; methods: string[] }> = []; let decoratorSource: string | null = null; let decoratorDepth = 0; for (const line of source.split("\n")) { @@ -1016,7 +1017,14 @@ function parseFastApiRoutes(filePath: string, source: string): FastApiRoute[] { const functionName = /^\s*(?:async\s+)?def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/u.exec(line)?.[1]; if (functionName !== undefined && pending.length > 0) { for (const item of pending) { - routes.push({ filePath, functionName, ...item }); + const prefix = prefixes.get(item.target) ?? ""; + const combinedPath = combineFastApiPaths(prefix, item.routePath); + routes.push({ + filePath, + functionName, + routePath: combinedPath, + methods: item.methods, + }); } pending = []; continue; @@ -1038,11 +1046,12 @@ function startsFastApiRouteDecorator(line: string): boolean { return fastApiRouteDecoratorStartPattern.test(line); } -function parseFastApiRouteDecorator(line: string): { routePath: string; methods: string[] } | null { +function parseFastApiRouteDecorator(line: string): { target: string; routePath: string; methods: string[] } | null { const match = fastApiRouteDecoratorPattern.exec(line); - const method = match?.[1]; - const args = match?.[2]; - if (method === undefined || args === undefined) { + const target = match?.[1]; + const method = match?.[2]; + const args = match?.[3]; + if (target === undefined || method === undefined || args === undefined) { return null; } const routePath = parseFastApiPath(args); @@ -1053,7 +1062,94 @@ function parseFastApiRouteDecorator(line: string): { routePath: string; methods: if (methods === null) { return null; } - return { routePath, methods }; + return { target, routePath, methods }; +} + +function parseFastApiRouterPrefixes(source: string): Map { + const prefixes = new Map(); + let index = 0; + while (true) { + const apiRouterIndex = source.indexOf("APIRouter(", index); + if (apiRouterIndex === -1) { + break; + } + index = apiRouterIndex + 10; + const prefixSegment = source.slice(0, apiRouterIndex); + const varMatch = /([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:[A-Za-z_][A-Za-z0-9_]*\.)?$/u.exec(prefixSegment.trimEnd()); + if (varMatch === null) { + continue; + } + const varName = varMatch[1]; + if (varName === undefined) { + continue; + } + const openParenIndex = apiRouterIndex + 9; + const closeParenIndex = findBalancedParenthesis(source, openParenIndex + 1); + if (closeParenIndex === -1) { + continue; + } + const argsStr = source.slice(openParenIndex + 1, closeParenIndex); + const args = splitTopLevelPythonArgs(argsStr); + for (const arg of args) { + const match = /^\s*prefix\s*=\s*(.*)$/u.exec(arg); + if (match?.[1] !== undefined) { + const val = pythonStringLiteralValue(match[1]); + if (val !== null) { + prefixes.set(varName, val); + break; + } + } + } + } + return prefixes; +} + +function findBalancedParenthesis(source: string, start: number): number { + let depth = 1; + let quote: string | null = null; + let escaped = false; + for (let index = start; index < source.length; index += 1) { + const char = source[index]; + if (char === undefined) { + break; + } + if (quote !== null) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === quote) { + quote = null; + } + continue; + } + if (char === '"' || char === "'") { + quote = char; + } else if (char === "(") { + depth += 1; + } else if (char === ")") { + depth -= 1; + if (depth === 0) { + return index; + } + } + } + return -1; +} + +function combineFastApiPaths(prefix: string, routePath: string): string { + const cleanPrefix = prefix.trim().replace(/^\/+/u, "").replace(/\/+$/u, ""); + const cleanPath = routePath.trim().replace(/^\/+/u, "").replace(/\/+$/u, ""); + if (cleanPrefix.length === 0 && cleanPath.length === 0) { + return "/"; + } + if (cleanPrefix.length === 0) { + return `/${cleanPath}`; + } + if (cleanPath.length === 0) { + return `/${cleanPrefix}`; + } + return `/${cleanPrefix}/${cleanPath}`; } function parseFastApiPath(args: string): string | null {