fix(fastapi): apply APIRouter constructor prefixes (#2312)

This commit is contained in:
Parafee41 2026-06-28 20:37:31 +08:00 • committed by GitHub
parent dd396bac53
commit 35ef360b11
14 changed files with 426 additions and 17 deletions

View file

@ -6,6 +6,7 @@ import {
unquoteLiteral,
type LanguagePatterns,
} from '../tree-sitter-scanner.js';
import { normalizeExtractedRoutePath } from '../../../ingestion/route-extractors/route-path.js';
import type { HttpDetection, HttpLanguagePlugin, RepoContext } from './types.js';
/**
@ -162,6 +163,26 @@ const INCLUDE_ROUTER_NAME_PATTERNS = compilePatterns({
],
} satisfies LanguagePatterns<Record<string, never>>);
const API_ROUTER_PREFIX_PATTERNS = compilePatterns({
name: 'python-fastapi-apirouter-prefix',
language: Python,
patterns: [
{
meta: {},
query: `
(assignment
left: (identifier) @router_name (#eq? @router_name "router")
right: (call
function: (identifier) @factory (#eq? @factory "APIRouter")
arguments: (argument_list
(keyword_argument
name: (identifier) @kw (#eq? @kw "prefix")
value: (string) @prefix))))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
// `from .api.assistant import router` style — used together with
// INCLUDE_ROUTER_NAME so we can map a local name back to its module
// path, then back to the file the router was declared in.
@ -905,10 +926,10 @@ function buildPythonRepoContext(
const prefixesByLongKey = new Map<string, Set<string>>();
const prefixesByShortKey = new Map<string, Set<string>>();
// Pre-pass over .py files. We deliberately run this even on files
// that don't contain `include_router` — the cost of an extra parse
// is bounded by the file count, and detecting `include_router`
// beforehand would require its own grep/scan.
// Cross-file pre-pass: only `include_router` sites need it — they bind a
// prefix declared in one file to a router defined in another. Same-file
// `APIRouter(prefix=...)` is resolved in scan() from the file's own tree, so
// APIRouter-only files are left out here and never parsed twice.
for (const rel of files) {
if (!rel.endsWith('.py')) continue;
const src = readFile(rel);
@ -1001,15 +1022,17 @@ function buildPythonRepoContext(
}
}
return { prefixesByLongKey, prefixesByShortKey };
return {
prefixesByLongKey,
prefixesByShortKey,
};
}
function joinPrefix(prefix: string, route: string): string {
// Mirror FastAPI's path joining: trim trailing slash off prefix,
// ensure exactly one leading slash on the result.
const p = prefix.replace(/\/+$/, '');
const r = route.startsWith('/') ? route : `/${route}`;
return `${p}${r}`;
// Delegate to the shared route-path normalizer so the group contract and the
// ingestion Route node join prefixes identically — one helper, no
// trailing-slash drift on empty routes (`APIRouter(prefix="/x")` + `@get("")`).
return normalizeExtractedRoutePath(route, prefix);
}
export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
name: 'python-http',
@ -1071,6 +1094,18 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
// the ingestion route extractor), not a per-file source scan — see the note
// at the top of this file.
// Same-file `router = APIRouter(prefix="/x")` (router-only). Read from this
// file's own tree, so there is no cross-file map and no prefix bleed across
// same-stem files; it stacks under any include_router(prefix=...) below.
let constructorPrefix: string | undefined;
for (const m of runCompiledPatterns(API_ROUTER_PREFIX_PATTERNS, tree)) {
const prefixNode = m.captures.prefix;
if (!prefixNode) continue;
const p = unquoteLiteral(prefixNode.text);
if (p === null) continue;
constructorPrefix = p;
}
// Providers: FastAPI @router.<verb>("/path") — must be joined
// with the prefix(es) declared at the include_router site. When
// no prefix is found we still emit the unprefixed path so this
@ -1095,10 +1130,13 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
const shortPrefixes =
longPrefixes || !shortKey ? undefined : ctx?.prefixesByShortKey.get(shortKey);
const prefixSet = longPrefixes ?? shortPrefixes;
// Stack the same-file APIRouter(prefix=...) under any cross-file
// include_router prefix.
const localPath = constructorPrefix ? joinPrefix(constructorPrefix, rawPath) : rawPath;
const paths =
prefixSet && prefixSet.size > 0
? [...prefixSet].map((p) => joinPrefix(p, rawPath))
: [rawPath];
? [...prefixSet].map((p) => joinPrefix(p, localPath))
: [localPath];
for (const p of paths) {
out.push({

View file

@ -22,6 +22,7 @@ import type {
FetchWrapperDef,
} from './workers/parse-worker.js';
import type {
ExtractedRouterConstructorPrefix,
ExtractedRouterImport,
ExtractedRouterInclude,
ExtractedRouterModuleAlias,
@ -37,6 +38,7 @@ export interface WorkerExtractedData {
decoratorRoutes: ExtractedDecoratorRoute[];
routerIncludes: ExtractedRouterInclude[];
routerImports: ExtractedRouterImport[];
routerConstructorPrefixes: ExtractedRouterConstructorPrefix[];
routerModuleAliases: ExtractedRouterModuleAlias[];
toolDefs: ExtractedToolDef[];
ormQueries: ExtractedORMQuery[];
@ -80,6 +82,7 @@ export const mergeChunkResults = (
const allDecoratorRoutes: ExtractedDecoratorRoute[] = [];
const allRouterIncludes: ExtractedRouterInclude[] = [];
const allRouterImports: ExtractedRouterImport[] = [];
const allRouterConstructorPrefixes: ExtractedRouterConstructorPrefix[] = [];
const allRouterModuleAliases: ExtractedRouterModuleAlias[] = [];
const allSpringTypes: SharedSpringType[] = [];
const allToolDefs: ExtractedToolDef[] = [];
@ -123,6 +126,9 @@ export const mergeChunkResults = (
for (const item of result.decoratorRoutes) allDecoratorRoutes.push(item);
for (const item of result.routerIncludes ?? []) allRouterIncludes.push(item);
for (const item of result.routerImports ?? []) allRouterImports.push(item);
for (const item of result.routerConstructorPrefixes ?? []) {
allRouterConstructorPrefixes.push(item);
}
for (const item of result.routerModuleAliases ?? []) allRouterModuleAliases.push(item);
for (const item of result.springTypes ?? []) allSpringTypes.push(item);
for (const item of result.toolDefs) allToolDefs.push(item);
@ -139,6 +145,7 @@ export const mergeChunkResults = (
decoratorRoutes: allDecoratorRoutes,
routerIncludes: allRouterIncludes,
routerImports: allRouterImports,
routerConstructorPrefixes: allRouterConstructorPrefixes,
routerModuleAliases: allRouterModuleAliases,
toolDefs: allToolDefs,
ormQueries: allORMQueries,

View file

@ -76,10 +76,12 @@ import type {
FetchWrapperDef,
} from '../workers/parse-worker.js';
import type {
ExtractedRouterConstructorPrefix,
ExtractedRouterImport,
ExtractedRouterInclude,
ExtractedRouterModuleAlias,
} from '../route-extractors/fastapi-router-bindings.js';
import { normalizeExtractedRoutePath } from '../route-extractors/route-path.js';
import {
resolveInheritedSpringRoutes,
type SharedSpringType,
@ -623,6 +625,7 @@ export async function runChunkedParseAndResolve(
const allDecoratorRoutes: ExtractedDecoratorRoute[] = [];
const allRouterIncludes: ExtractedRouterInclude[] = [];
const allRouterImports: ExtractedRouterImport[] = [];
const allRouterConstructorPrefixes: ExtractedRouterConstructorPrefix[] = [];
const allRouterModuleAliases: ExtractedRouterModuleAlias[] = [];
const allSpringTypes: SharedSpringType[] = [];
const allToolDefs: ExtractedToolDef[] = [];
@ -779,6 +782,11 @@ export async function runChunkedParseAndResolve(
if (chunkWorkerData.routerImports?.length) {
for (const item of chunkWorkerData.routerImports) allRouterImports.push(item);
}
if (chunkWorkerData.routerConstructorPrefixes?.length) {
for (const item of chunkWorkerData.routerConstructorPrefixes) {
allRouterConstructorPrefixes.push(item);
}
}
if (chunkWorkerData.routerModuleAliases?.length) {
for (const item of chunkWorkerData.routerModuleAliases) allRouterModuleAliases.push(item);
}
@ -1156,7 +1164,10 @@ export async function runChunkedParseAndResolve(
// decorator inherits its file-basename's prefix. When a router is mounted
// under multiple prefixes we duplicate the route entry, mirroring FastAPI's
// runtime behaviour.
if (allRouterIncludes.length > 0 && allDecoratorRoutes.length > 0) {
if (
(allRouterIncludes.length > 0 || allRouterConstructorPrefixes.length > 0) &&
allDecoratorRoutes.length > 0
) {
// Group `routerImports` by file so we can resolve Shape-B locals against
// imports declared in the SAME file as the include_router call. We carry
// both the short module key (file basename) and, when available, the long
@ -1201,6 +1212,11 @@ export async function runChunkedParseAndResolve(
// without a corresponding import statement).
const prefixesByLongKey = new Map<string, Set<string>>();
const prefixesByShortKey = new Map<string, Set<string>>();
// Constructor prefixes are `router`-only (the apply gate below and the
// group-layer tree-sitter both pin to the literal name `router`), so a
// flat file-key → prefix map suffices — mirrors the group layer's shape.
const constructorPrefixesByLongKey = new Map<string, string>();
const constructorPrefixesByShortKey = new Map<string, string>();
const recordPrefix = (target: Map<string, Set<string>>, key: string, prefix: string): void => {
let set = target.get(key);
@ -1241,7 +1257,11 @@ export async function runChunkedParseAndResolve(
}
}
if (prefixesByLongKey.size > 0 || prefixesByShortKey.size > 0) {
if (
prefixesByLongKey.size > 0 ||
prefixesByShortKey.size > 0 ||
allRouterConstructorPrefixes.length > 0
) {
const fileLongKey = (rel: string): string => {
// Strip `.py`, then take the last two path segments. `api/users.py`
// → `api/users`. Files at the repo root return the empty string,
@ -1263,6 +1283,15 @@ export async function runChunkedParseAndResolve(
return file.endsWith('.py') ? file.slice(0, -3) : file;
};
for (const ctor of allRouterConstructorPrefixes) {
const longKey = fileLongKey(ctor.filePath);
if (longKey) {
constructorPrefixesByLongKey.set(longKey, ctor.prefix);
} else {
constructorPrefixesByShortKey.set(fileShortKey(ctor.filePath), ctor.prefix);
}
}
const expanded: ExtractedDecoratorRoute[] = [];
for (const dr of allDecoratorRoutes) {
if (dr.decoratorReceiver !== 'router' || !dr.filePath.endsWith('.py')) {
@ -1278,12 +1307,24 @@ export async function runChunkedParseAndResolve(
? undefined
: prefixesByShortKey.get(fileShortKey(dr.filePath));
const prefixes = longPrefixes ?? shortPrefixes;
// Constructor prefixes are keyed like include_router prefixes:
// long-key entries are precise, while short-key entries are only
// valid for repo-root/single-segment files where `fileLongKey`
// returns ''. Do not fall back from a missing long-key match to the
// short key or a root `users.py` prefix can leak onto
// `admin/users.py`.
const constructorPrefix = longKey
? constructorPrefixesByLongKey.get(longKey)
: constructorPrefixesByShortKey.get(fileShortKey(dr.filePath));
const routePath = constructorPrefix
? normalizeExtractedRoutePath(dr.routePath, constructorPrefix)
: dr.routePath;
if (!prefixes || prefixes.size === 0) {
expanded.push(dr);
expanded.push(routePath === dr.routePath ? dr : { ...dr, routePath });
continue;
}
for (const prefix of prefixes) {
expanded.push({ ...dr, prefix });
expanded.push({ ...dr, routePath, prefix });
}
}
allDecoratorRoutes.length = 0;

View file

@ -105,6 +105,11 @@ export interface ExtractedRouterModuleAlias {
moduleKeyLong: string;
}
export interface ExtractedRouterConstructorPrefix {
filePath: string;
prefix: string;
}
// `<host>.include_router(<module>.router, ..., prefix='/x')` (Shape A).
// `<host>` is left unrestricted — common production names include
// `app`, `api`, `application`, `asgi_app`. Pinning to the literal
@ -122,6 +127,8 @@ const INCLUDE_ROUTER_NAME_RE =
// The latter is the common case and the only one we can map back to
// a module stem.
const FROM_IMPORT_ROUTER_RE = /^\s*from\s+(\.+|\.*[A-Za-z_][\w.]*)\s+import\s+([^#\n]+)/gm;
const API_ROUTER_ASSIGN_RE = /\brouter\s*=\s*APIRouter\s*\(/g;
const API_ROUTER_PREFIX_ARG_RE = /\bprefix\s*=\s*(['"])([^'"]*)\1/;
/**
* Last `.`-separated segment of a (possibly relative) Python module
@ -157,6 +164,35 @@ export function lastTwoSegmentsAsPath(text: string): string {
return `${parent}/${stem}`;
}
function findMatchingParen(content: string, openIndex: number): number {
let depth = 0;
let quote: string | null = null;
for (let i = openIndex; i < content.length; i++) {
const ch = content[i];
if (quote) {
if (ch === '\\') {
i++;
continue;
}
if (ch === quote) quote = null;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if (ch === '(' || ch === '[' || ch === '{') {
depth++;
continue;
}
if (ch === ')' || ch === ']' || ch === '}') {
depth--;
if (depth === 0 && ch === ')') return i;
}
}
return -1;
}
/**
* Scan a single Python file's source text for FastAPI router
* `include_router` sites and `from <module> import router` imports,
@ -176,6 +212,7 @@ export function extractFastAPIRouterBindings(
outIncludes: ExtractedRouterInclude[],
outImports: ExtractedRouterImport[],
outModuleAliases?: ExtractedRouterModuleAlias[],
outConstructorPrefixes?: ExtractedRouterConstructorPrefix[],
): void {
if (!content.includes('include_router') && !content.includes('router')) return;
@ -239,6 +276,24 @@ export function extractFastAPIRouterBindings(
}
}
if (outConstructorPrefixes && content.includes('APIRouter') && content.includes('prefix')) {
API_ROUTER_ASSIGN_RE.lastIndex = 0;
// Only `router = APIRouter(...)` is captured (the apply gate and the
// group-layer tree-sitter both pin to the literal name `router`).
while (API_ROUTER_ASSIGN_RE.exec(content) !== null) {
const openParen = API_ROUTER_ASSIGN_RE.lastIndex - 1;
const closeParen = findMatchingParen(content, openParen);
if (closeParen < 0) continue;
const args = content.slice(openParen + 1, closeParen);
const prefixMatch = API_ROUTER_PREFIX_ARG_RE.exec(args);
if (!prefixMatch) continue;
outConstructorPrefixes.push({
filePath,
prefix: prefixMatch[2],
});
}
}
if (!content.includes('include_router')) return;
// Shape A: `<host>.include_router(<module>.router, prefix='/x')`.

View file

@ -30,6 +30,7 @@ import { postResultCloneSafe } from './post-result.js';
import { mergeResult } from './result-merge.js';
import type { SymbolTableReader } from '../model/symbol-table.js';
import type {
ExtractedRouterConstructorPrefix,
ExtractedRouterInclude,
ExtractedRouterImport,
ExtractedRouterModuleAlias,
@ -394,6 +395,7 @@ export interface ParseWorkerResult {
decoratorRoutes: ExtractedDecoratorRoute[];
routerIncludes: ExtractedRouterInclude[];
routerImports: ExtractedRouterImport[];
routerConstructorPrefixes?: ExtractedRouterConstructorPrefix[];
/**
* Optional. Project-wide `SharedSpringType` view of route-defining
* class/interface declarations, produced by the provider's
@ -906,6 +908,7 @@ const processBatch = (
decoratorRoutes: [],
routerIncludes: [],
routerImports: [],
routerConstructorPrefixes: [],
routerModuleAliases: [],
toolDefs: [],
ormQueries: [],
@ -2423,6 +2426,7 @@ const processFileGroup = (
result.routerIncludes,
result.routerImports,
(result.routerModuleAliases ??= []),
(result.routerConstructorPrefixes ??= []),
);
}
@ -2475,6 +2479,7 @@ let accumulated: ParseWorkerResult = {
decoratorRoutes: [],
routerIncludes: [],
routerImports: [],
routerConstructorPrefixes: [],
routerModuleAliases: [],
toolDefs: [],
ormQueries: [],
@ -2615,6 +2620,7 @@ parentPort!.on('message', (msg: WorkerIncomingMessage) => {
decoratorRoutes: [],
routerIncludes: [],
routerImports: [],
routerConstructorPrefixes: [],
routerModuleAliases: [],
toolDefs: [],
ormQueries: [],

View file

@ -37,6 +37,10 @@ export const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult):
appendAll(target.decoratorRoutes, src.decoratorRoutes);
if (src.routerIncludes) appendAll(target.routerIncludes, src.routerIncludes);
if (src.routerImports) appendAll(target.routerImports, src.routerImports);
if (src.routerConstructorPrefixes) {
target.routerConstructorPrefixes ??= [];
appendAll(target.routerConstructorPrefixes, src.routerConstructorPrefixes);
}
if (src.routerModuleAliases) {
target.routerModuleAliases ??= [];
appendAll(target.routerModuleAliases, src.routerModuleAliases);

View file

@ -55,7 +55,7 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
// the main thread (the #1983 OOM). Because the two stores share this version,
// any future change to the `ParsedFile` serialization shape MUST bump
// SCHEMA_BUMP so both invalidate in lockstep.
const SCHEMA_BUMP = 8; // #2288: ParseWorkerResult gained `springTypes` (Spring interface-inheritance) + `decoratorRoutes` semantics changed (interface routes suppressed at extraction, inherited routes appended in parse-impl)
const SCHEMA_BUMP = 9; // #2312: ParseWorkerResult gained `routerConstructorPrefixes` for FastAPI APIRouter(prefix=...) replay
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from

View file

@ -0,0 +1,17 @@
from fastapi import APIRouter, Depends
def get_db():
return None
# Router-level dependency listed BEFORE `prefix=` — exercises the
# balanced-paren scan end-to-end. The old `[^)]*?` regex stopped at the
# `)` of `Depends(...)` and dropped the prefix, leaving the Route node
# unprefixed; this route must still resolve to `/v1/items/{item_id}`.
router = APIRouter(dependencies=[Depends(get_db)], prefix="/items")
@router.get("/{item_id}")
def get_item(item_id: str):
return {"id": item_id}

View file

@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter(prefix="/local")
@router.get("")
def list_local():
return []

View file

@ -1,4 +1,5 @@
from fastapi import FastAPI
from api import items
from api import users
from api.calls import router as calls_router
from .relative import router as rel_router
@ -8,6 +9,7 @@ from .relative import router as rel_router
# tree-sitter pattern. Pinning to literal `app` would silently drop
# every prefix here.
application = FastAPI()
application.include_router(items.router, prefix="/v1")
application.include_router(users.router, prefix="/users", tags=["users"])
application.include_router(calls_router, prefix="/calls")
application.include_router(rel_router, prefix="/rel")

View file

@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter(prefix="/root-users")
@router.get("/landing")
def root_users_landing():
return {}

View file

@ -30,6 +30,13 @@
* ingestion regex. The group-layer counterpart is pinned by the
* `non-app host` cases in `http-route-extractor.test.ts`.
*
* 5. **Same-file `APIRouter(prefix=…)`** is applied to `@router`
* decorators, and stacks with an outer `include_router(prefix=…)`.
*
* 6. **Root-file constructor prefixes do not bleed into nested same-stem
* files.** A repo-root `users.py` can use `APIRouter(prefix=…)`, but
* `admin/users.py` must not inherit that constructor prefix.
*
* The fixture lives at `test/fixtures/fastapi-prefix-app/` so the
* pipeline can scan a real on-disk repo (mirroring how `gitnexus
* analyze` is used in production) and so reviewers can inspect the
@ -96,6 +103,24 @@ describe('FastAPI include_router(prefix=…) — ingestion pipeline', () => {
expect(names).toContain('/rel/info');
});
it('joins same-file APIRouter(prefix=…) with router decorator paths', () => {
const names = routeNames();
expect(names).toContain('/local');
expect(names).toContain('/root-users/landing');
expect(names.filter((n) => n === '/')).toHaveLength(0);
});
it('stacks same-file APIRouter(prefix=…) with include_router(prefix=…)', () => {
// api/items.py declares `APIRouter(dependencies=[Depends(get_db)], prefix="/items")`
// — a router-level dependency BEFORE prefix=. This pins the balanced-paren
// scan end-to-end: the old `[^)]*?` regex stopped at the `)` of `Depends(...)`
// and dropped `/items`, leaving the graph Route node at `/v1/{item_id}`.
const names = routeNames();
expect(names).toContain('/v1/items/{item_id}');
expect(names.filter((n) => n === '/items/{item_id}')).toHaveLength(0);
expect(names.filter((n) => n === '/v1/{item_id}')).toHaveLength(0);
});
it('does NOT bleed `/users` prefix onto the same-name `admin/users.py`', () => {
// FINDING 3: `api/users.py` and `admin/users.py` collide on the
// short module key `users`. main.py only mounts the `api/users`
@ -106,6 +131,13 @@ describe('FastAPI include_router(prefix=…) — ingestion pipeline', () => {
expect(names.filter((n) => n === '/users/audit')).toHaveLength(0);
});
it('does NOT bleed root same-file APIRouter(prefix=…) onto nested same-stem files', () => {
const names = routeNames();
expect(names).toContain('/root-users/landing');
expect(names).toContain('/audit');
expect(names.filter((n) => n === '/root-users/audit')).toHaveLength(0);
});
it('emits exactly one Route node per (router method, prefix) pair', () => {
// Defence-in-depth: counts the unique route nodes for the
// prefixed routes to make sure the duplication path in
@ -118,6 +150,8 @@ describe('FastAPI include_router(prefix=…) — ingestion pipeline', () => {
expect(counts.get('/users/create')).toBe(1);
expect(counts.get('/calls/list')).toBe(1);
expect(counts.get('/rel/info')).toBe(1);
expect(counts.get('/local')).toBe(1);
expect(counts.get('/v1/items/{item_id}')).toBe(1);
expect(counts.get('/audit')).toBe(1);
});
});

View file

@ -29,8 +29,10 @@ import {
extractFastAPIRouterBindings,
lastDottedSegment,
lastTwoSegmentsAsPath,
type ExtractedRouterConstructorPrefix,
type ExtractedRouterInclude,
type ExtractedRouterImport,
type ExtractedRouterModuleAlias,
} from '../../src/core/ingestion/route-extractors/fastapi-router-bindings.js';
function run(filePath: string, content: string) {
@ -40,6 +42,22 @@ function run(filePath: string, content: string) {
return { includes, imports };
}
function runFull(filePath: string, content: string) {
const includes: ExtractedRouterInclude[] = [];
const imports: ExtractedRouterImport[] = [];
const moduleAliases: ExtractedRouterModuleAlias[] = [];
const constructorPrefixes: ExtractedRouterConstructorPrefix[] = [];
extractFastAPIRouterBindings(
filePath,
content,
includes,
imports,
moduleAliases,
constructorPrefixes,
);
return { includes, imports, moduleAliases, constructorPrefixes };
}
describe('lastDottedSegment', () => {
it('returns the last segment of an absolute dotted path', () => {
expect(lastDottedSegment('api.users')).toBe('users');
@ -285,3 +303,56 @@ describe('extractFastAPIRouterBindings — negative cases', () => {
expect(imports[0].moduleKey).toBe('users');
});
});
describe('extractFastAPIRouterBindings — APIRouter constructor prefix', () => {
it('captures same-file APIRouter(prefix=...) declarations', () => {
const { constructorPrefixes } = runFull(
'api/items.py',
[
'from fastapi import APIRouter',
'router = APIRouter(prefix="/api/items", tags=["items"])',
'',
'@router.get("")',
'def list_items():',
' return []',
'',
].join('\n'),
);
expect(constructorPrefixes).toEqual([{ filePath: 'api/items.py', prefix: '/api/items' }]);
});
it('captures prefix after nested APIRouter arguments', () => {
const { constructorPrefixes } = runFull(
'api/items.py',
[
'from fastapi import APIRouter, Depends',
'router = APIRouter(dependencies=[Depends(get_db)], prefix="/api/items")',
'',
'@router.get("")',
'def list_items():',
' return []',
'',
].join('\n'),
);
expect(constructorPrefixes).toEqual([{ filePath: 'api/items.py', prefix: '/api/items' }]);
});
it('does not emit constructor prefixes for non-router receivers yet', () => {
const { constructorPrefixes } = runFull(
'api/items.py',
[
'from fastapi import APIRouter',
'api_router = APIRouter(prefix="/api")',
'',
'@api_router.get("/items")',
'def list_items():',
' return []',
'',
].join('\n'),
);
expect(constructorPrefixes).toEqual([]);
});
});

View file

@ -6969,6 +6969,124 @@ async def concurrent():
expect(providers.find((c) => c.contractId === 'http::GET::/ai/concurrent')).toBeDefined();
});
it('joins FastAPI @router.<verb> path with APIRouter(prefix=...) in the same file', async () => {
const dir = path.join(tmpDir, 'fastapi-router-constructor-prefix');
fs.mkdirSync(path.join(dir, 'api'), { recursive: true });
fs.writeFileSync(path.join(dir, 'main.py'), `app = None\n`);
fs.writeFileSync(
path.join(dir, 'api/items.py'),
`from fastapi import APIRouter
router = APIRouter(prefix="/api/items", tags=["items"])
@router.get("")
async def list_items():
return []
@router.post("/{item_id}")
async def update_item(item_id: str):
return {}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.find((c) => c.contractId === 'http::GET::/api/items')).toBeDefined();
expect(
providers.find((c) => c.contractId === 'http::POST::/api/items/{param}'),
).toBeDefined();
expect(providers.find((c) => c.contractId === 'http::GET::/')).toBeUndefined();
expect(providers.find((c) => c.contractId === 'http::POST::/{param}')).toBeUndefined();
});
it('treats an empty APIRouter(prefix="") as no prefix', async () => {
const dir = path.join(tmpDir, 'fastapi-router-empty-prefix');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'main.py'), `app = None\n`);
fs.writeFileSync(
path.join(dir, 'items.py'),
`from fastapi import APIRouter
router = APIRouter(prefix="")
@router.get("/list")
async def list_items():
return []
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
// Empty prefix is a clean no-op — the route keeps its bare decorator path.
expect(providers.find((c) => c.contractId === 'http::GET::/list')).toBeDefined();
});
it('does not bleed root APIRouter(prefix=...) onto nested same-stem files', async () => {
const dir = path.join(tmpDir, 'fastapi-router-constructor-prefix-no-bleed');
fs.mkdirSync(path.join(dir, 'admin'), { recursive: true });
fs.writeFileSync(path.join(dir, 'main.py'), `app = None\n`);
fs.writeFileSync(
path.join(dir, 'users.py'),
`from fastapi import APIRouter
router = APIRouter(prefix="/root-users")
@router.get("/landing")
async def root_users_landing():
return {}
`,
);
fs.writeFileSync(
path.join(dir, 'admin/users.py'),
`from fastapi import APIRouter
router = APIRouter()
@router.get("/audit")
async def audit():
return {}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(
providers.find((c) => c.contractId === 'http::GET::/root-users/landing'),
).toBeDefined();
expect(providers.find((c) => c.contractId === 'http::GET::/audit')).toBeDefined();
expect(
providers.find((c) => c.contractId === 'http::GET::/root-users/audit'),
).toBeUndefined();
});
it('stacks FastAPI APIRouter(prefix=...) with include_router(prefix=...)', async () => {
const dir = path.join(tmpDir, 'fastapi-router-stacked-prefix');
fs.mkdirSync(path.join(dir, 'api'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'main.py'),
`from fastapi import FastAPI
from api import items
app = FastAPI()
app.include_router(items.router, prefix="/v1")
`,
);
fs.writeFileSync(
path.join(dir, 'api/items.py'),
`from fastapi import APIRouter
router = APIRouter(prefix="/items")
@router.get("/{item_id}")
async def get_item(item_id: str):
return {}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.find((c) => c.contractId === 'http::GET::/v1/items/{param}')).toBeDefined();
expect(providers.find((c) => c.contractId === 'http::GET::/items/{param}')).toBeUndefined();
});
it('emits @router.<verb> path unmodified when no include_router prefix is configured', async () => {
const dir = path.join(tmpDir, 'fastapi-router-no-prefix');
fs.mkdirSync(path.join(dir, 'api'), { recursive: true });