mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-29 06:32:28 +00:00
fix(fastapi): apply APIRouter constructor prefixes (#2312)
This commit is contained in:
parent
dd396bac53
commit
35ef360b11
14 changed files with 426 additions and 17 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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')`.
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
17
gitnexus/test/fixtures/fastapi-prefix-app/api/items.py
vendored
Normal file
17
gitnexus/test/fixtures/fastapi-prefix-app/api/items.py
vendored
Normal 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}
|
||||
8
gitnexus/test/fixtures/fastapi-prefix-app/local.py
vendored
Normal file
8
gitnexus/test/fixtures/fastapi-prefix-app/local.py
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(prefix="/local")
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_local():
|
||||
return []
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
8
gitnexus/test/fixtures/fastapi-prefix-app/users.py
vendored
Normal file
8
gitnexus/test/fixtures/fastapi-prefix-app/users.py
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(prefix="/root-users")
|
||||
|
||||
|
||||
@router.get("/landing")
|
||||
def root_users_landing():
|
||||
return {}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue