Skip to content
Merged
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
48 changes: 40 additions & 8 deletions autobot-backend/api/codebase_analytics/api_endpoint_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -655,15 +655,18 @@ def _registry_router_files(self) -> Dict[Path, str]:
carries the registered prefix, so the routes land under the path they
are actually served on.

Deliberately limited to module *files*. A registry entry naming a
package cannot have its prefix applied to the modules inside it: the
LLC router registers as ``("llc.api", "", …)`` -- an empty prefix --
and its real ``/api/llc/*`` paths come from ``include_router`` calls in
the package's own ``__init__.py``. Applying the package's prefix to
each submodule produced ``/api/costs/…`` instead of ``/api/llc/costs/…``:
A registry entry naming a *package* needs its own ``__init__.py`` read
first (#12945). LLC registers as ``("llc.api", "", …)`` -- an empty
prefix -- while its real ``/api/llc/*`` paths come from the package
router declared there:

llc/api/__init__.py: router = APIRouter(prefix="/llc")
llc/api/costs.py: router = APIRouter(prefix="/costs")

so a submodule serves ``/api`` + ``/llc`` + ``/costs``. Applying the
registry prefix alone to each submodule yields ``/api/costs/…`` --
182 endpoints of which 4 were real. Inventing endpoints is worse than
missing them, since they resurface as phantom "orphaned" findings.
Packages need the include_router walk and are left to that follow-up.
"""
files: Dict[Path, str] = {}
for module_path, prefix in self._module_prefix_map.items():
Expand All @@ -674,11 +677,40 @@ def _registry_router_files(self) -> Dict[Path, str]:
if module_path.startswith("api."):
continue # already covered by the api/ walk

module_file = self.backend_dir.joinpath(*module_path.split(".")).with_suffix(".py")
target = self.backend_dir.joinpath(*module_path.split("."))
module_file = target.with_suffix(".py")
if module_file.is_file():
files[module_file] = prefix
elif (target / "__init__.py").is_file():
files.update(self._package_router_files(target, prefix))
return files

def _package_router_files(self, package: Path, registry_prefix: str) -> Dict[Path, str]:
"""Map a registry-mounted package's submodules to their served prefix.

The package's own ``APIRouter(prefix=...)`` sits between the registry
prefix and each submodule's router prefix, and ``_scan_file`` applies
the submodule's own prefix separately -- so only the package-level part
belongs here.

Submodules are included only when the package actually mounts them via
``include_router``: a helper module that defines no router contributes
no routes, and guessing otherwise is how phantom endpoints appear.
"""
init_file = package / "__init__.py"
init_content = init_file.read_text(encoding="utf-8", errors="ignore")
package_prefix = self._get_file_router_prefix(init_content) or ""
if not _INCLUDE_ROUTER_RE.search(init_content):
return {}

served_prefix = f"{registry_prefix}{package_prefix}"
return {
py_file: served_prefix
for py_file in sorted(package.rglob("*.py"))
if not py_file.name.startswith("__")
and _APIROUTER_PREFIX_RE.search(py_file.read_text(encoding="utf-8", errors="ignore"))
}

def _get_module_prefix(self, file_path: Path) -> str:
"""Get the API prefix for a given file based on router registry."""
# #12945: registry-mounted files outside api/ carry their prefix
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,22 +196,75 @@ def test_api_modules_are_not_duplicated_by_the_external_walk(self, tmp_path):

assert scanner._registry_router_files() == {}

def test_package_entries_are_skipped(self, tmp_path):
"""#12945: a package's prefix does not apply to the modules inside it.

LLC registers as ``("llc.api", "", …)`` and its real ``/api/llc/*``
paths come from include_router calls in the package's own __init__.py.
Applying the empty package prefix per-submodule invented 182 endpoints
of which 4 were real -- worse than missing them.
"""
backend = self._backend_with_registry(tmp_path, [("llc.api", "")])
@staticmethod
def _make_package(backend, init_body, submodules):
"""Create a registry-mounted package with its own router."""
pkg = backend / "llc" / "api"
pkg.mkdir(parents=True)
(pkg / "costs.py").write_text('@router.get("/by-agent")\ndef c(): ...\n', encoding="utf-8")
(pkg / "__init__.py").write_text(init_body, encoding="utf-8")
for name, body in submodules.items():
(pkg / name).write_text(body, encoding="utf-8")
return pkg

def test_package_prefix_comes_from_its_own_router(self, tmp_path):
"""#12945: the package router sits between registry and submodule.

LLC registers as ``("llc.api", "", …)`` -- an empty prefix -- while its
real paths come from ``APIRouter(prefix="/llc")`` in the package's own
__init__.py. Using the registry prefix alone yielded ``/api/costs/…``
instead of ``/api/llc/costs/…``: 182 endpoints of which 4 were real.
"""
backend = self._backend_with_registry(tmp_path, [("llc.api", "")])
pkg = self._make_package(
backend,
'router = APIRouter(prefix="/llc")\nrouter.include_router(costs_router)\n',
{"costs.py": 'router = APIRouter(prefix="/costs")\n@router.get("/by-agent")\ndef c(): ...\n'},
)

scanner = self._scanner_for(tmp_path, None)
found = self._scanner_for(tmp_path, None)._registry_router_files()

assert scanner._registry_router_files() == {}
assert found == {pkg / "costs.py": "/api/llc"}

def test_package_submodule_route_gets_the_full_served_path(self, tmp_path):
"""/api + /llc (package) + /costs (submodule) + route."""
backend = self._backend_with_registry(tmp_path, [("llc.api", "")])
self._make_package(
backend,
'router = APIRouter(prefix="/llc")\nrouter.include_router(costs_router)\n',
{"costs.py": 'router = APIRouter(prefix="/costs")\n@router.get("/by-agent")\ndef c(): ...\n'},
)

paths = [e.path for e in scanner_mod.BackendEndpointScanner(project_root=tmp_path).scan_all_endpoints()]

assert "/api/llc/costs/by-agent" in paths

def test_package_helper_modules_without_a_router_are_skipped(self, tmp_path):
"""A module that defines no router serves nothing -- guessing invents endpoints."""
backend = self._backend_with_registry(tmp_path, [("llc.api", "")])
pkg = self._make_package(
backend,
'router = APIRouter(prefix="/llc")\nrouter.include_router(costs_router)\n',
{
"costs.py": 'router = APIRouter(prefix="/costs")\n@router.get("/x")\ndef c(): ...\n',
"helpers.py": "def compute(): return 1\n",
},
)

found = self._scanner_for(tmp_path, None)._registry_router_files()

assert pkg / "helpers.py" not in found
assert pkg / "costs.py" in found

def test_package_that_mounts_nothing_is_skipped(self, tmp_path):
"""No include_router means the entry is not a router package."""
backend = self._backend_with_registry(tmp_path, [("llc.api", "")])
self._make_package(
backend,
'VERSION = "1"\n',
{"costs.py": 'router = APIRouter(prefix="/costs")\n@router.get("/x")\ndef c(): ...\n'},
)

assert self._scanner_for(tmp_path, None)._registry_router_files() == {}

def test_missing_module_file_is_ignored(self, tmp_path):
"""A registry entry whose module is absent must not break the scan."""
Expand Down
Loading