Skip to content

Commit 841cd00

Browse files
🐛 Fix incorrect selection of root when app and router defined in same file (#9)
1 parent bf28619 commit 841cd00

File tree

4 files changed

+145
-21
lines changed

4 files changed

+145
-21
lines changed

src/core/routerResolver.ts

Lines changed: 58 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@ export type { RouterNode }
99

1010
/**
1111
* Finds the main FastAPI app or APIRouter in the list of routers.
12+
* We want to prioritize FastAPI apps over APIRouters.
1213
*/
1314
function findAppRouter(routers: RouterInfo[]): RouterInfo | undefined {
14-
return routers.find((r) => r.type === "FastAPI" || r.type === "APIRouter")
15+
return (
16+
routers.find((r) => r.type === "FastAPI") ??
17+
routers.find((r) => r.type === "APIRouter")
18+
)
1519
}
1620

1721
/**
@@ -36,13 +40,14 @@ function buildRouterGraphInternal(
3640
): RouterNode | null {
3741
// Resolve the full path of the entry file if necessary
3842
let resolvedEntryFile = entryFile
43+
3944
if (!existsSync(resolvedEntryFile) && !isAbsolute(entryFile)) {
4045
resolvedEntryFile = join(projectRoot, entryFile)
4146
}
47+
4248
if (!existsSync(resolvedEntryFile)) {
4349
return null
4450
}
45-
4651
// Prevent infinite recursion on circular imports
4752
if (visited.has(resolvedEntryFile)) {
4853
return null
@@ -85,6 +90,10 @@ function buildRouterGraphInternal(
8590
}
8691

8792
// Find all routers included in the app
93+
// Only include routes that belong directly to the app (not to local APIRouters)
94+
const appRoutes = analysis.routes.filter(
95+
(r) => r.object === appRouter.variableName,
96+
)
8897
const rootRouter: RouterNode = {
8998
filePath: resolvedEntryFile,
9099
variableName: appRouter.variableName,
@@ -93,7 +102,7 @@ function buildRouterGraphInternal(
93102
tags: appRouter.tags,
94103
line: appRouter.line,
95104
column: appRouter.column,
96-
routes: analysis.routes.map((r) => ({
105+
routes: appRoutes.map((r) => ({
97106
method: r.method,
98107
path: r.path,
99108
function: r.function,
@@ -113,18 +122,17 @@ function buildRouterGraphInternal(
113122
parser,
114123
visited,
115124
)
116-
if (!childRouter) {
117-
continue
118-
}
119-
// Merge tags from include_router call with the router's own tags
120-
if (include.tags.length > 0) {
121-
childRouter.tags = [...new Set([...childRouter.tags, ...include.tags])]
125+
if (childRouter) {
126+
// Merge tags from include_router call with the router's own tags
127+
if (include.tags.length > 0) {
128+
childRouter.tags = [...new Set([...childRouter.tags, ...include.tags])]
129+
}
130+
rootRouter.children.push({
131+
router: childRouter,
132+
prefix: include.prefix,
133+
tags: include.tags,
134+
})
122135
}
123-
rootRouter.children.push({
124-
router: childRouter,
125-
prefix: include.prefix,
126-
tags: include.tags,
127-
})
128136
}
129137

130138
// Process mount() calls for subapps
@@ -137,21 +145,21 @@ function buildRouterGraphInternal(
137145
parser,
138146
visited,
139147
)
140-
if (!childRouter) {
141-
continue
148+
if (childRouter) {
149+
rootRouter.children.push({
150+
router: childRouter,
151+
prefix: mount.path,
152+
tags: [],
153+
})
142154
}
143-
rootRouter.children.push({
144-
router: childRouter,
145-
prefix: mount.path,
146-
tags: [],
147-
})
148155
}
149156

150157
return rootRouter
151158
}
152159

153160
/**
154161
* Resolves a router/app reference to its RouterNode.
162+
* Used for include_router and mount calls.
155163
*/
156164
function resolveRouterReference(
157165
reference: string,
@@ -164,9 +172,37 @@ function resolveRouterReference(
164172
const parts = reference.split(".")
165173
const moduleName = parts[0]
166174

175+
// First, check if this is a local router defined in the same file
176+
const localRouter = analysis.routers.find(
177+
(r) => r.variableName === moduleName && r.type === "APIRouter",
178+
)
179+
if (localRouter) {
180+
// Filter routes that belong to this router (decorated with @router.method)
181+
const routerRoutes = analysis.routes.filter((r) => r.object === moduleName)
182+
return {
183+
filePath: currentFile,
184+
variableName: localRouter.variableName,
185+
type: localRouter.type,
186+
prefix: localRouter.prefix,
187+
tags: localRouter.tags,
188+
line: localRouter.line,
189+
column: localRouter.column,
190+
routes: routerRoutes.map((r) => ({
191+
method: r.method,
192+
path: r.path,
193+
function: r.function,
194+
line: r.line,
195+
column: r.column,
196+
})),
197+
children: [],
198+
}
199+
}
200+
201+
// Otherwise, look for an imported router
167202
const matchingImport = analysis.imports.find((imp) =>
168203
imp.names.includes(moduleName),
169204
)
205+
170206
if (!matchingImport) {
171207
return null
172208
}
@@ -182,6 +218,7 @@ function resolveRouterReference(
182218
projectRoot,
183219
parser,
184220
)
221+
185222
if (!importedFilePath) {
186223
return null
187224
}

src/test/core/routerResolver.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,5 +184,60 @@ suite("routerResolver", () => {
184184
"users router should have routes",
185185
)
186186
})
187+
188+
test("prioritizes FastAPI over APIRouter in same file", () => {
189+
const result = buildRouterGraph(
190+
fixtures.sameFile.mainPy,
191+
parser,
192+
fixtures.sameFile.root,
193+
)
194+
195+
assert.ok(result)
196+
assert.strictEqual(result.type, "FastAPI")
197+
assert.strictEqual(result.variableName, "app")
198+
})
199+
200+
test("assigns routes to correct owner in same file", () => {
201+
const result = buildRouterGraph(
202+
fixtures.sameFile.mainPy,
203+
parser,
204+
fixtures.sameFile.root,
205+
)
206+
207+
assert.ok(result)
208+
209+
// App routes should only include @app.xxx routes
210+
const appRoutePaths = result.routes.map((r) => r.path)
211+
assert.ok(
212+
!appRoutePaths.includes("/items"),
213+
"App should not have /items route (belongs to router)",
214+
)
215+
})
216+
217+
test("resolves local router as child in same file", () => {
218+
const result = buildRouterGraph(
219+
fixtures.sameFile.mainPy,
220+
parser,
221+
fixtures.sameFile.root,
222+
)
223+
224+
assert.ok(result)
225+
assert.strictEqual(
226+
result.children.length,
227+
1,
228+
"Should have one child router",
229+
)
230+
231+
const apiRouter = result.children[0]
232+
assert.strictEqual(apiRouter.router.type, "APIRouter")
233+
assert.strictEqual(apiRouter.router.prefix, "/api")
234+
235+
// Router should have its own routes
236+
const routerRoutePaths = apiRouter.router.routes.map((r) => r.path)
237+
assert.ok(
238+
routerRoutePaths.includes("/items"),
239+
"Router should have /items route",
240+
)
241+
})
187242
})
188243
})
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from fastapi import APIRouter, FastAPI
2+
3+
app = FastAPI(title="Same File Layout")
4+
5+
router = APIRouter(prefix="/api")
6+
7+
8+
@router.get("/items")
9+
def get_items():
10+
return {"items": []}
11+
12+
13+
@router.post("/items")
14+
def create_item():
15+
return {"item": "created"}
16+
17+
18+
app.include_router(router)
19+
20+
21+
@app.get("/")
22+
def root():
23+
return {"message": "Hello from same file layout"}
24+
25+
26+
@app.get("/health")
27+
def health():
28+
return {"status": "ok"}

src/test/testUtils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,8 @@ export const fixtures = {
3535
"__init__.py",
3636
),
3737
},
38+
sameFile: {
39+
root: join(fixturesPath, "same-file"),
40+
mainPy: join(fixturesPath, "same-file", "main.py"),
41+
},
3842
}

0 commit comments

Comments
 (0)