apisec-code-bolt 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apisec_code_bolt/__init__.py +42 -0
- apisec_code_bolt/__main__.py +11 -0
- apisec_code_bolt/analysis/__init__.py +96 -0
- apisec_code_bolt/analysis/analyzer.py +2309 -0
- apisec_code_bolt/analysis/binding_tracker.py +341 -0
- apisec_code_bolt/analysis/call_graph.py +1197 -0
- apisec_code_bolt/analysis/call_graph_types.py +332 -0
- apisec_code_bolt/analysis/call_resolver.py +988 -0
- apisec_code_bolt/analysis/capability_tagger.py +322 -0
- apisec_code_bolt/analysis/config_scanner.py +197 -0
- apisec_code_bolt/analysis/data_flow.py +1883 -0
- apisec_code_bolt/analysis/dependency_extractor.py +959 -0
- apisec_code_bolt/analysis/flow_analysis.py +1406 -0
- apisec_code_bolt/analysis/hof_catalog.py +61 -0
- apisec_code_bolt/analysis/integration_detector.py +1399 -0
- apisec_code_bolt/analysis/literal_scanner.py +300 -0
- apisec_code_bolt/analysis/path_normalizer.py +55 -0
- apisec_code_bolt/analysis/read_site_detector.py +310 -0
- apisec_code_bolt/analysis/request_patterns.py +162 -0
- apisec_code_bolt/analysis/sensitivity_classifier.py +224 -0
- apisec_code_bolt/analysis/sink_evidence.py +333 -0
- apisec_code_bolt/analysis/url_prefix_resolver.py +338 -0
- apisec_code_bolt/cli/__init__.py +5 -0
- apisec_code_bolt/cli/exit_codes.py +17 -0
- apisec_code_bolt/cli/main.py +1069 -0
- apisec_code_bolt/cloud/__init__.py +1 -0
- apisec_code_bolt/cloud/apisec_client.py +118 -0
- apisec_code_bolt/cloud/client.py +255 -0
- apisec_code_bolt/core/__init__.py +75 -0
- apisec_code_bolt/core/config.py +528 -0
- apisec_code_bolt/core/credentials.py +65 -0
- apisec_code_bolt/core/discovery.py +433 -0
- apisec_code_bolt/core/log_format.py +115 -0
- apisec_code_bolt/core/manifest.py +1009 -0
- apisec_code_bolt/core/repo.py +280 -0
- apisec_code_bolt/core/state.py +59 -0
- apisec_code_bolt/core/telemetry.py +451 -0
- apisec_code_bolt/core/types.py +587 -0
- apisec_code_bolt/fingerprinting/__init__.py +1 -0
- apisec_code_bolt/frameworks/__init__.py +29 -0
- apisec_code_bolt/frameworks/_jwt_common.py +50 -0
- apisec_code_bolt/frameworks/auth_helpers.py +437 -0
- apisec_code_bolt/frameworks/base.py +608 -0
- apisec_code_bolt/frameworks/dotnet/__init__.py +17 -0
- apisec_code_bolt/frameworks/dotnet/_path_helpers.py +43 -0
- apisec_code_bolt/frameworks/dotnet/aspnet_plugin.py +2546 -0
- apisec_code_bolt/frameworks/dotnet/grpc_plugin.py +559 -0
- apisec_code_bolt/frameworks/dotnet/jwt_config_extractor.py +545 -0
- apisec_code_bolt/frameworks/dotnet/legacy_aspnet_plugin.py +732 -0
- apisec_code_bolt/frameworks/dotnet/refit_plugin.py +374 -0
- apisec_code_bolt/frameworks/dotnet/wcf_plugin.py +1239 -0
- apisec_code_bolt/frameworks/java/__init__.py +6 -0
- apisec_code_bolt/frameworks/java/_annotations.py +167 -0
- apisec_code_bolt/frameworks/java/_constraints.py +128 -0
- apisec_code_bolt/frameworks/java/graphql_plugin.py +287 -0
- apisec_code_bolt/frameworks/java/jaxrs_plugin.py +748 -0
- apisec_code_bolt/frameworks/java/jwt_config_extractor.py +361 -0
- apisec_code_bolt/frameworks/java/micronaut_plugin.py +1059 -0
- apisec_code_bolt/frameworks/java/spring_plugin.py +1293 -0
- apisec_code_bolt/frameworks/js/__init__.py +8 -0
- apisec_code_bolt/frameworks/js/express_plugin.py +391 -0
- apisec_code_bolt/frameworks/js/fastify_plugin.py +381 -0
- apisec_code_bolt/frameworks/js/graphql_plugin.py +198 -0
- apisec_code_bolt/frameworks/js/nestjs_plugin.py +423 -0
- apisec_code_bolt/frameworks/python/__init__.py +19 -0
- apisec_code_bolt/frameworks/python/celery_plugin.py +393 -0
- apisec_code_bolt/frameworks/python/click_plugin.py +427 -0
- apisec_code_bolt/frameworks/python/django_plugin.py +867 -0
- apisec_code_bolt/frameworks/python/fastapi/__init__.py +28 -0
- apisec_code_bolt/frameworks/python/fastapi/plugin.py +1390 -0
- apisec_code_bolt/frameworks/python/flask_plugin.py +205 -0
- apisec_code_bolt/frameworks/python/graphql_plugin.py +274 -0
- apisec_code_bolt/frameworks/python/prefect_plugin.py +251 -0
- apisec_code_bolt/frameworks/python/webhook_plugin.py +255 -0
- apisec_code_bolt/parsing/__init__.py +62 -0
- apisec_code_bolt/parsing/base.py +554 -0
- apisec_code_bolt/parsing/csharp/__init__.py +5 -0
- apisec_code_bolt/parsing/csharp/language_services.py +203 -0
- apisec_code_bolt/parsing/csharp/literals.py +72 -0
- apisec_code_bolt/parsing/csharp/parser.py +1158 -0
- apisec_code_bolt/parsing/csharp/type_resolver.py +568 -0
- apisec_code_bolt/parsing/js/__init__.py +5 -0
- apisec_code_bolt/parsing/js/language_services.py +118 -0
- apisec_code_bolt/parsing/js/parser.py +622 -0
- apisec_code_bolt/parsing/jvm/__init__.py +7 -0
- apisec_code_bolt/parsing/jvm/language_services.py +270 -0
- apisec_code_bolt/parsing/jvm/parser.py +774 -0
- apisec_code_bolt/parsing/jvm/type_resolver.py +422 -0
- apisec_code_bolt/parsing/python/__init__.py +150 -0
- apisec_code_bolt/parsing/python/cbv_extractor.py +606 -0
- apisec_code_bolt/parsing/python/constant_resolver.py +500 -0
- apisec_code_bolt/parsing/python/cross_file_resolver.py +1054 -0
- apisec_code_bolt/parsing/python/dynamic_route_detector.py +532 -0
- apisec_code_bolt/parsing/python/expression_utils.py +221 -0
- apisec_code_bolt/parsing/python/extraction_types.py +271 -0
- apisec_code_bolt/parsing/python/language_services.py +487 -0
- apisec_code_bolt/parsing/python/parameter_analyzer.py +789 -0
- apisec_code_bolt/parsing/python/parser.py +719 -0
- apisec_code_bolt/parsing/python/path_resolver.py +576 -0
- apisec_code_bolt/parsing/python/router_registry.py +806 -0
- apisec_code_bolt/parsing/python/type_resolver.py +730 -0
- apisec_code_bolt/parsing/python/visitors.py +1544 -0
- apisec_code_bolt/parsing/services.py +544 -0
- apisec_code_bolt/query/__init__.py +1 -0
- apisec_code_bolt/query/ast_cache.py +182 -0
- apisec_code_bolt/query/executor.py +283 -0
- apisec_code_bolt/query/handlers.py +832 -0
- apisec_code_bolt-0.1.0.dist-info/METADATA +230 -0
- apisec_code_bolt-0.1.0.dist-info/RECORD +111 -0
- apisec_code_bolt-0.1.0.dist-info/WHEEL +4 -0
- apisec_code_bolt-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Router registry for tracking FastAPI/Starlette routers and their relationships.
|
|
3
|
+
|
|
4
|
+
This module handles:
|
|
5
|
+
- Tracking router variable definitions (app = FastAPI(), router = APIRouter())
|
|
6
|
+
- Tracking include_router() calls with prefix resolution
|
|
7
|
+
- Building the final router tree with merged prefixes
|
|
8
|
+
- Resolving route paths to their final URLs
|
|
9
|
+
- Resolving constant-based prefixes (settings.PREFIX, config.api_prefix)
|
|
10
|
+
|
|
11
|
+
CRITICAL: This enables correct path resolution in multi-file FastAPI applications
|
|
12
|
+
where routers are defined in one file and included with prefixes in another.
|
|
13
|
+
|
|
14
|
+
PHILOSOPHY: Even if prefix values CAN be overridden at runtime, resolve the
|
|
15
|
+
default/static value. This captures the most common deployment configuration.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import TYPE_CHECKING
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from ..base import ParsedAssignment, ParsedFile
|
|
27
|
+
from .constant_resolver import ConstantResolver
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# =============================================================================
|
|
31
|
+
# Data Types
|
|
32
|
+
# =============================================================================
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class RouterDefinition:
|
|
37
|
+
"""Definition of a router or app variable."""
|
|
38
|
+
|
|
39
|
+
name: str # Variable name (e.g., "app", "router", "user_router")
|
|
40
|
+
file_path: Path
|
|
41
|
+
line: int
|
|
42
|
+
|
|
43
|
+
# What type is it?
|
|
44
|
+
router_type: str # "FastAPI", "APIRouter", "Starlette"
|
|
45
|
+
|
|
46
|
+
# Constructor arguments
|
|
47
|
+
prefix: str = "" # For APIRouter(prefix="/api")
|
|
48
|
+
tags: list[str] = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
# Dependencies declared on the constructor (e.g. APIRouter(dependencies=[...]))
|
|
51
|
+
constructor_dependencies: list[str] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
# Qualified name for cross-file reference
|
|
54
|
+
qualified_name: str = ""
|
|
55
|
+
|
|
56
|
+
# Is this a re-assignment?
|
|
57
|
+
source_router: str | None = None # For router = other_router
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class RouterInclusion:
|
|
62
|
+
"""Record of an include_router() call."""
|
|
63
|
+
|
|
64
|
+
# Where the inclusion happens
|
|
65
|
+
file_path: Path
|
|
66
|
+
line: int
|
|
67
|
+
|
|
68
|
+
# The parent (app/router being added to)
|
|
69
|
+
parent_var: str
|
|
70
|
+
|
|
71
|
+
# The router being included
|
|
72
|
+
included_router: str # Variable name or module.var reference
|
|
73
|
+
included_router_qualified: str | None = None # Resolved qualified name
|
|
74
|
+
|
|
75
|
+
# Arguments to include_router
|
|
76
|
+
prefix: str = "" # Path prefix to add
|
|
77
|
+
tags: list[str] = field(default_factory=list)
|
|
78
|
+
dependencies: list[str] = field(default_factory=list)
|
|
79
|
+
|
|
80
|
+
# Resolution status
|
|
81
|
+
resolved: bool = False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class RouterNode:
|
|
86
|
+
"""Node in the router tree."""
|
|
87
|
+
|
|
88
|
+
definition: RouterDefinition
|
|
89
|
+
|
|
90
|
+
# Parent router (None for root FastAPI apps)
|
|
91
|
+
parent: RouterNode | None = None
|
|
92
|
+
|
|
93
|
+
# Included routers
|
|
94
|
+
children: list[RouterNode] = field(default_factory=list)
|
|
95
|
+
|
|
96
|
+
# Computed full prefix (including all parent prefixes)
|
|
97
|
+
computed_prefix: str = ""
|
|
98
|
+
|
|
99
|
+
# All tags (inherited + own)
|
|
100
|
+
computed_tags: list[str] = field(default_factory=list)
|
|
101
|
+
|
|
102
|
+
# Accumulated dependency function names from all ancestor include_router()
|
|
103
|
+
# calls. For example, if a parent router is included with
|
|
104
|
+
# ``dependencies=[Depends(get_current_user)]``, every child route
|
|
105
|
+
# inherits ``["get_current_user"]``.
|
|
106
|
+
computed_dependencies: list[str] = field(default_factory=list)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class RouterTree:
|
|
111
|
+
"""Complete router tree for a project."""
|
|
112
|
+
|
|
113
|
+
# All router definitions by qualified name
|
|
114
|
+
routers: dict[str, RouterDefinition] = field(default_factory=dict)
|
|
115
|
+
|
|
116
|
+
# All inclusions
|
|
117
|
+
inclusions: list[RouterInclusion] = field(default_factory=list)
|
|
118
|
+
|
|
119
|
+
# Root nodes (FastAPI apps)
|
|
120
|
+
roots: list[RouterNode] = field(default_factory=list)
|
|
121
|
+
|
|
122
|
+
# Variable name to qualified name mapping per file
|
|
123
|
+
var_to_qualified: dict[Path, dict[str, str]] = field(default_factory=dict)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# =============================================================================
|
|
127
|
+
# Router Registry
|
|
128
|
+
# =============================================================================
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class RouterRegistry:
|
|
132
|
+
"""
|
|
133
|
+
Tracks router definitions and inclusions across a project.
|
|
134
|
+
|
|
135
|
+
Supports resolving constant-based prefixes like:
|
|
136
|
+
- settings.API_PREFIX
|
|
137
|
+
- config.prefix
|
|
138
|
+
- os.getenv("PREFIX", "/api")
|
|
139
|
+
|
|
140
|
+
Usage:
|
|
141
|
+
registry = RouterRegistry()
|
|
142
|
+
|
|
143
|
+
# Add parsed files
|
|
144
|
+
for parsed in parsed_files:
|
|
145
|
+
registry.process_file(parsed)
|
|
146
|
+
|
|
147
|
+
# Optionally set constant resolver for config values
|
|
148
|
+
registry.set_constant_resolver(constant_resolver)
|
|
149
|
+
|
|
150
|
+
# Build the router tree
|
|
151
|
+
tree = registry.build_tree()
|
|
152
|
+
|
|
153
|
+
# Get the full prefix for a route
|
|
154
|
+
full_path = registry.resolve_path(router_var, route_path, file_path)
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
def __init__(self, project_root: Path | None = None):
|
|
158
|
+
"""Initialize the registry."""
|
|
159
|
+
self._project_root = project_root
|
|
160
|
+
self._routers: dict[str, RouterDefinition] = {}
|
|
161
|
+
self._inclusions: list[RouterInclusion] = []
|
|
162
|
+
self._var_to_qualified: dict[Path, dict[str, str]] = {}
|
|
163
|
+
self._tree: RouterTree | None = None
|
|
164
|
+
self._import_mappings: dict[Path, dict[str, str]] = {}
|
|
165
|
+
self._constant_resolver: ConstantResolver | None = None
|
|
166
|
+
|
|
167
|
+
def set_constant_resolver(self, resolver: ConstantResolver) -> None:
|
|
168
|
+
"""Set a constant resolver for resolving config-based prefixes."""
|
|
169
|
+
self._constant_resolver = resolver
|
|
170
|
+
|
|
171
|
+
def process_file(self, parsed: ParsedFile) -> None:
|
|
172
|
+
"""Process a parsed file to extract router information."""
|
|
173
|
+
if not parsed.success:
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
file_path = parsed.path
|
|
177
|
+
self._var_to_qualified[file_path] = {}
|
|
178
|
+
self._import_mappings[file_path] = {}
|
|
179
|
+
|
|
180
|
+
# Build import mappings first
|
|
181
|
+
self._build_import_mappings(parsed)
|
|
182
|
+
|
|
183
|
+
# Find router definitions in assignments
|
|
184
|
+
for assign in parsed.assignments:
|
|
185
|
+
router_def = self._extract_router_definition(assign, file_path)
|
|
186
|
+
if router_def:
|
|
187
|
+
qualified = self._make_qualified_name(router_def.name, file_path)
|
|
188
|
+
router_def.qualified_name = qualified
|
|
189
|
+
self._routers[qualified] = router_def
|
|
190
|
+
self._var_to_qualified[file_path][router_def.name] = qualified
|
|
191
|
+
|
|
192
|
+
# Find include_router calls
|
|
193
|
+
for call in parsed.call_sites:
|
|
194
|
+
inclusion = self._extract_router_inclusion(call, file_path)
|
|
195
|
+
if inclusion:
|
|
196
|
+
self._inclusions.append(inclusion)
|
|
197
|
+
|
|
198
|
+
def _build_import_mappings(self, parsed: ParsedFile) -> None:
|
|
199
|
+
"""Build import alias mappings for the file."""
|
|
200
|
+
file_path = parsed.path
|
|
201
|
+
mappings = {}
|
|
202
|
+
|
|
203
|
+
for imp in parsed.imports:
|
|
204
|
+
if imp.is_from_import:
|
|
205
|
+
for name in imp.names:
|
|
206
|
+
local_name = imp.alias if len(imp.names) == 1 and imp.alias else name
|
|
207
|
+
mappings[local_name] = f"{imp.module}.{name}" if imp.module else name
|
|
208
|
+
else:
|
|
209
|
+
local_name = imp.alias or imp.module.split(".")[-1]
|
|
210
|
+
mappings[local_name] = imp.module
|
|
211
|
+
|
|
212
|
+
self._import_mappings[file_path] = mappings
|
|
213
|
+
|
|
214
|
+
def _extract_router_definition(
|
|
215
|
+
self,
|
|
216
|
+
assign: ParsedAssignment,
|
|
217
|
+
file_path: Path,
|
|
218
|
+
) -> RouterDefinition | None:
|
|
219
|
+
"""Extract router definition from an assignment."""
|
|
220
|
+
if assign.source_type != "call":
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
called = assign.source_call or ""
|
|
224
|
+
|
|
225
|
+
# Resolve aliases
|
|
226
|
+
resolved = self._resolve_call_name(called, file_path)
|
|
227
|
+
|
|
228
|
+
# Check for router types
|
|
229
|
+
router_type = None
|
|
230
|
+
if resolved in {"FastAPI", "fastapi.FastAPI", "fastapi.applications.FastAPI"}:
|
|
231
|
+
router_type = "FastAPI"
|
|
232
|
+
elif resolved in {"APIRouter", "fastapi.APIRouter", "fastapi.routing.APIRouter"}:
|
|
233
|
+
router_type = "APIRouter"
|
|
234
|
+
elif resolved in {"Starlette", "starlette.applications.Starlette"}:
|
|
235
|
+
router_type = "Starlette"
|
|
236
|
+
elif resolved in {"Router", "starlette.routing.Router"}:
|
|
237
|
+
router_type = "Router"
|
|
238
|
+
|
|
239
|
+
if not router_type:
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
# Extract constructor arguments
|
|
243
|
+
source = assign.value_source or ""
|
|
244
|
+
prefix = self._extract_string_kwarg(source, "prefix")
|
|
245
|
+
tags = self._extract_list_kwarg(source, "tags")
|
|
246
|
+
|
|
247
|
+
# Extract constructor-level dependencies (e.g. APIRouter(dependencies=[...]))
|
|
248
|
+
constructor_deps: list[str] = []
|
|
249
|
+
dep_match = re.findall(r"Depends\s*\(\s*([\w.]+)\s*\)", source)
|
|
250
|
+
if dep_match and "dependencies" in source:
|
|
251
|
+
constructor_deps = [f"Depends({d})" for d in dep_match]
|
|
252
|
+
|
|
253
|
+
return RouterDefinition(
|
|
254
|
+
name=assign.target,
|
|
255
|
+
file_path=file_path,
|
|
256
|
+
line=assign.location.line,
|
|
257
|
+
router_type=router_type,
|
|
258
|
+
prefix=prefix,
|
|
259
|
+
tags=tags,
|
|
260
|
+
constructor_dependencies=constructor_deps,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
def _extract_router_inclusion(
|
|
264
|
+
self,
|
|
265
|
+
call,
|
|
266
|
+
file_path: Path,
|
|
267
|
+
) -> RouterInclusion | None:
|
|
268
|
+
"""Extract include_router call information."""
|
|
269
|
+
# Check if this is an include_router call
|
|
270
|
+
callee = call.callee_name
|
|
271
|
+
if not callee.endswith("include_router"):
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
# Extract parent router name
|
|
275
|
+
parts = callee.rsplit(".", 1)
|
|
276
|
+
parent_var = parts[0] if len(parts) > 1 else ""
|
|
277
|
+
|
|
278
|
+
if not parent_var:
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
# Extract the router being included (first argument)
|
|
282
|
+
included_router = ""
|
|
283
|
+
prefix = ""
|
|
284
|
+
tags: list[str] = []
|
|
285
|
+
dependencies: list[str] = []
|
|
286
|
+
|
|
287
|
+
for arg in call.arguments:
|
|
288
|
+
if arg.position == 0 or arg.name == "router":
|
|
289
|
+
included_router = arg.variable_name or arg.expression_text or ""
|
|
290
|
+
elif arg.name == "prefix":
|
|
291
|
+
prefix = self._extract_string_value(arg, file_path)
|
|
292
|
+
elif arg.name == "tags":
|
|
293
|
+
tags = self._extract_list_value(arg)
|
|
294
|
+
elif arg.name == "dependencies":
|
|
295
|
+
dependencies = self._extract_list_value(arg)
|
|
296
|
+
|
|
297
|
+
if not included_router:
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
# Try to resolve qualified name
|
|
301
|
+
qualified = self._resolve_included_router(included_router, file_path)
|
|
302
|
+
|
|
303
|
+
return RouterInclusion(
|
|
304
|
+
file_path=file_path,
|
|
305
|
+
line=call.location.line,
|
|
306
|
+
parent_var=parent_var,
|
|
307
|
+
included_router=included_router,
|
|
308
|
+
included_router_qualified=qualified,
|
|
309
|
+
prefix=prefix,
|
|
310
|
+
tags=tags,
|
|
311
|
+
dependencies=dependencies,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
def _resolve_call_name(self, name: str, file_path: Path) -> str:
|
|
315
|
+
"""Resolve a call name using import mappings."""
|
|
316
|
+
mappings = self._import_mappings.get(file_path, {})
|
|
317
|
+
|
|
318
|
+
parts = name.split(".")
|
|
319
|
+
if parts[0] in mappings:
|
|
320
|
+
resolved_first = mappings[parts[0]]
|
|
321
|
+
if len(parts) > 1:
|
|
322
|
+
return f"{resolved_first}.{'.'.join(parts[1:])}"
|
|
323
|
+
return resolved_first
|
|
324
|
+
|
|
325
|
+
return name
|
|
326
|
+
|
|
327
|
+
def _resolve_import_reference(self, ref: str, file_path: Path) -> str | None:
|
|
328
|
+
"""Resolve a module.var reference to a qualified name."""
|
|
329
|
+
mappings = self._import_mappings.get(file_path, {})
|
|
330
|
+
|
|
331
|
+
parts = ref.split(".", 1)
|
|
332
|
+
if parts[0] in mappings:
|
|
333
|
+
module = mappings[parts[0]]
|
|
334
|
+
if len(parts) > 1:
|
|
335
|
+
return f"{module}.{parts[1]}"
|
|
336
|
+
return module
|
|
337
|
+
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
def _resolve_included_router(
|
|
341
|
+
self,
|
|
342
|
+
included_router: str,
|
|
343
|
+
file_path: Path,
|
|
344
|
+
) -> str | None:
|
|
345
|
+
"""Resolve an included router reference to its qualified name.
|
|
346
|
+
|
|
347
|
+
Tries three strategies in order:
|
|
348
|
+
1. Dotted reference (``module.router``) — resolve via imports
|
|
349
|
+
2. Local variable defined in the same file (``my_router = APIRouter()``)
|
|
350
|
+
3. Import alias (``from views import router as my_router``) — resolve
|
|
351
|
+
the import to a module-qualified name then match against known
|
|
352
|
+
routers registered from other files.
|
|
353
|
+
"""
|
|
354
|
+
if "." in included_router:
|
|
355
|
+
return self._resolve_import_reference(included_router, file_path)
|
|
356
|
+
|
|
357
|
+
# Local variable defined in this file
|
|
358
|
+
if file_path in self._var_to_qualified:
|
|
359
|
+
qualified = self._var_to_qualified[file_path].get(included_router)
|
|
360
|
+
if qualified:
|
|
361
|
+
return qualified
|
|
362
|
+
|
|
363
|
+
# Import alias — the variable is imported, not locally defined.
|
|
364
|
+
# _import_mappings maps the local name to a module-qualified path
|
|
365
|
+
# (e.g. "ai_router" → "dispatch.ai.prompt.views.router"). We need
|
|
366
|
+
# to find the matching entry in _routers which uses file-system
|
|
367
|
+
# qualified names (e.g. "src.dispatch.ai.prompt.views.router").
|
|
368
|
+
mappings = self._import_mappings.get(file_path, {})
|
|
369
|
+
module_qualified = mappings.get(included_router)
|
|
370
|
+
if module_qualified:
|
|
371
|
+
matched = self._match_module_to_router(module_qualified)
|
|
372
|
+
if matched:
|
|
373
|
+
return matched
|
|
374
|
+
|
|
375
|
+
return None
|
|
376
|
+
|
|
377
|
+
def _match_module_to_router(self, module_qualified: str) -> str | None:
|
|
378
|
+
"""Match a Python module path to a registered router's qualified name.
|
|
379
|
+
|
|
380
|
+
The registry keys are file-system based (``src.dispatch.auth.views.router``)
|
|
381
|
+
while import mappings produce module paths (``dispatch.auth.views.router``).
|
|
382
|
+
We match by checking if any registered router's qualified name ends with
|
|
383
|
+
the module path, handling the ``src.`` / ``src/`` prefix difference.
|
|
384
|
+
"""
|
|
385
|
+
# Exact match first (unlikely but cheap)
|
|
386
|
+
if module_qualified in self._routers:
|
|
387
|
+
return module_qualified
|
|
388
|
+
|
|
389
|
+
# Suffix match: "dispatch.auth.views.router" matches
|
|
390
|
+
# "src.dispatch.auth.views.router"
|
|
391
|
+
suffix = f".{module_qualified}"
|
|
392
|
+
for qualified_name in self._routers:
|
|
393
|
+
if qualified_name.endswith(suffix) or qualified_name == module_qualified:
|
|
394
|
+
return qualified_name
|
|
395
|
+
|
|
396
|
+
return None
|
|
397
|
+
|
|
398
|
+
def _make_qualified_name(self, var_name: str, file_path: Path) -> str:
|
|
399
|
+
"""Create a qualified name for a variable."""
|
|
400
|
+
# Use file path as module identifier
|
|
401
|
+
rel_path = file_path
|
|
402
|
+
if self._project_root and file_path.is_relative_to(self._project_root):
|
|
403
|
+
rel_path = file_path.relative_to(self._project_root)
|
|
404
|
+
|
|
405
|
+
module = str(rel_path.with_suffix("")).replace("/", ".").replace("\\", ".")
|
|
406
|
+
return f"{module}.{var_name}"
|
|
407
|
+
|
|
408
|
+
def _extract_string_kwarg(self, source: str, key: str) -> str:
|
|
409
|
+
"""Extract a string keyword argument from source code."""
|
|
410
|
+
import re
|
|
411
|
+
|
|
412
|
+
pattern = rf'{key}\s*=\s*["\']([^"\']*)["\']'
|
|
413
|
+
match = re.search(pattern, source)
|
|
414
|
+
return match.group(1) if match else ""
|
|
415
|
+
|
|
416
|
+
def _extract_list_kwarg(self, source: str, key: str) -> list[str]:
|
|
417
|
+
"""Extract a list keyword argument from source code."""
|
|
418
|
+
import re
|
|
419
|
+
|
|
420
|
+
pattern = rf"{key}\s*=\s*\[([^\]]*)\]"
|
|
421
|
+
match = re.search(pattern, source)
|
|
422
|
+
if not match:
|
|
423
|
+
return []
|
|
424
|
+
|
|
425
|
+
items = match.group(1)
|
|
426
|
+
# Extract quoted strings
|
|
427
|
+
strings = re.findall(r'["\']([^"\']*)["\']', items)
|
|
428
|
+
return strings
|
|
429
|
+
|
|
430
|
+
def _extract_string_value(self, arg, file_path: Path | None = None) -> str:
|
|
431
|
+
"""
|
|
432
|
+
Extract string value from a call argument.
|
|
433
|
+
|
|
434
|
+
Handles:
|
|
435
|
+
- Literal strings: "/api"
|
|
436
|
+
- Variable references: PREFIX (resolved via constant resolver)
|
|
437
|
+
- Attribute access: settings.PREFIX (resolved via constant resolver)
|
|
438
|
+
- Env vars with defaults: os.getenv("PREFIX", "/api") -> "/api"
|
|
439
|
+
"""
|
|
440
|
+
# 1. Direct literal value
|
|
441
|
+
if arg.literal_value and isinstance(arg.literal_value, str):
|
|
442
|
+
return arg.literal_value
|
|
443
|
+
|
|
444
|
+
if arg.expression_text:
|
|
445
|
+
expr = arg.expression_text.strip()
|
|
446
|
+
|
|
447
|
+
# 2. Try to extract literal string from expression
|
|
448
|
+
string_match = re.search(r'^["\']([^"\']*)["\']$', expr)
|
|
449
|
+
if string_match:
|
|
450
|
+
return string_match.group(1)
|
|
451
|
+
|
|
452
|
+
# 3. Try constant resolver for variable/attribute access
|
|
453
|
+
if self._constant_resolver:
|
|
454
|
+
resolved = self._constant_resolver.resolve(expr, file_path)
|
|
455
|
+
if resolved:
|
|
456
|
+
return resolved.value
|
|
457
|
+
|
|
458
|
+
# 4. Try to extract default from os.getenv
|
|
459
|
+
env_match = re.search(
|
|
460
|
+
r'(?:os\.)?(?:getenv|environ\.get)\s*\(\s*["\'][^"\']+["\']\s*,\s*["\']([^"\']*)["\']',
|
|
461
|
+
expr,
|
|
462
|
+
)
|
|
463
|
+
if env_match:
|
|
464
|
+
return env_match.group(1)
|
|
465
|
+
|
|
466
|
+
# 5. Fallback: try to extract any string in the expression
|
|
467
|
+
fallback_match = re.search(r'["\']([^"\']*)["\']', expr)
|
|
468
|
+
if fallback_match:
|
|
469
|
+
return fallback_match.group(1)
|
|
470
|
+
|
|
471
|
+
# 6. Try variable name resolution
|
|
472
|
+
if arg.variable_name and self._constant_resolver:
|
|
473
|
+
resolved = self._constant_resolver.resolve(arg.variable_name, file_path)
|
|
474
|
+
if resolved:
|
|
475
|
+
return resolved.value
|
|
476
|
+
|
|
477
|
+
return ""
|
|
478
|
+
|
|
479
|
+
def _extract_list_value(self, arg) -> list[str]:
|
|
480
|
+
"""Extract list value from a call argument.
|
|
481
|
+
|
|
482
|
+
Handles three representations:
|
|
483
|
+
1. Actual Python list in literal_value
|
|
484
|
+
2. String-encoded list in literal_value (e.g. ``'[Depends(func)]'``)
|
|
485
|
+
3. expression_text containing a list expression
|
|
486
|
+
"""
|
|
487
|
+
# Real Python list
|
|
488
|
+
if isinstance(arg.literal_value, list):
|
|
489
|
+
return [str(v) for v in arg.literal_value]
|
|
490
|
+
|
|
491
|
+
# Pick the best raw text to parse
|
|
492
|
+
raw = ""
|
|
493
|
+
if isinstance(arg.literal_value, str) and arg.literal_value.strip().startswith("["):
|
|
494
|
+
raw = arg.literal_value
|
|
495
|
+
elif arg.expression_text and str(arg.expression_text) not in ("None", ""):
|
|
496
|
+
raw = str(arg.expression_text)
|
|
497
|
+
|
|
498
|
+
if raw:
|
|
499
|
+
# Quoted strings (tags, etc.)
|
|
500
|
+
strings = re.findall(r'["\']([^"\']*)["\']', raw)
|
|
501
|
+
if strings:
|
|
502
|
+
return strings
|
|
503
|
+
# Depends() expressions (dependencies lists)
|
|
504
|
+
depends = re.findall(r"Depends\s*\(\s*([\w.]+)\s*\)", raw)
|
|
505
|
+
if depends:
|
|
506
|
+
return [f"Depends({d})" for d in depends]
|
|
507
|
+
|
|
508
|
+
return []
|
|
509
|
+
|
|
510
|
+
# =========================================================================
|
|
511
|
+
# Tree Building
|
|
512
|
+
# =========================================================================
|
|
513
|
+
|
|
514
|
+
def build_tree(self) -> RouterTree:
|
|
515
|
+
"""Build the complete router tree."""
|
|
516
|
+
tree = RouterTree(
|
|
517
|
+
routers=dict(self._routers),
|
|
518
|
+
inclusions=list(self._inclusions),
|
|
519
|
+
var_to_qualified=dict(self._var_to_qualified),
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
# Deferred resolution: re-resolve inclusions whose qualified name
|
|
523
|
+
# couldn't be determined during process_file() because the target
|
|
524
|
+
# router's file hadn't been processed yet.
|
|
525
|
+
for inclusion in self._inclusions:
|
|
526
|
+
if inclusion.included_router_qualified is None:
|
|
527
|
+
inclusion.included_router_qualified = self._resolve_included_router(
|
|
528
|
+
inclusion.included_router, inclusion.file_path
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
# Create nodes for all routers
|
|
532
|
+
nodes: dict[str, RouterNode] = {}
|
|
533
|
+
for qualified, router_def in self._routers.items():
|
|
534
|
+
nodes[qualified] = RouterNode(
|
|
535
|
+
definition=router_def,
|
|
536
|
+
computed_prefix=router_def.prefix,
|
|
537
|
+
computed_tags=list(router_def.tags),
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
# Index inclusions by child for the propagation pass
|
|
541
|
+
inclusions_by_child: dict[str, RouterInclusion] = {}
|
|
542
|
+
|
|
543
|
+
# Resolve inclusions and build parent-child relationships
|
|
544
|
+
for inclusion in self._inclusions:
|
|
545
|
+
parent_qualified = self._resolve_parent(inclusion)
|
|
546
|
+
child_qualified = inclusion.included_router_qualified
|
|
547
|
+
|
|
548
|
+
if not parent_qualified or not child_qualified:
|
|
549
|
+
continue
|
|
550
|
+
|
|
551
|
+
parent_node = nodes.get(parent_qualified)
|
|
552
|
+
child_node = nodes.get(child_qualified)
|
|
553
|
+
|
|
554
|
+
if not parent_node or not child_node:
|
|
555
|
+
continue
|
|
556
|
+
|
|
557
|
+
# Set up relationship
|
|
558
|
+
child_node.parent = parent_node
|
|
559
|
+
parent_node.children.append(child_node)
|
|
560
|
+
|
|
561
|
+
# Compute full prefix
|
|
562
|
+
child_node.computed_prefix = self._join_paths(
|
|
563
|
+
parent_node.computed_prefix,
|
|
564
|
+
inclusion.prefix,
|
|
565
|
+
child_node.definition.prefix,
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
# Merge tags
|
|
569
|
+
child_node.computed_tags = (
|
|
570
|
+
parent_node.computed_tags + inclusion.tags + child_node.definition.tags
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
# Propagate dependencies — parent's accumulated deps + this
|
|
574
|
+
# inclusion's own deps flow to all routes under the child.
|
|
575
|
+
inclusion_dep_names = self._parse_depends_names(inclusion.dependencies)
|
|
576
|
+
child_node.computed_dependencies = (
|
|
577
|
+
list(parent_node.computed_dependencies) + inclusion_dep_names
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
inclusion.resolved = True
|
|
581
|
+
inclusions_by_child[child_qualified] = inclusion
|
|
582
|
+
|
|
583
|
+
# Find root nodes (no parent)
|
|
584
|
+
for node in nodes.values():
|
|
585
|
+
if node.parent is None:
|
|
586
|
+
tree.roots.append(node)
|
|
587
|
+
|
|
588
|
+
# Propagate prefixes and dependencies down the tree. The initial
|
|
589
|
+
# assignment above only uses the parent's state at processing time,
|
|
590
|
+
# which may be incomplete for deeply nested trees. A top-down pass
|
|
591
|
+
# from each root ensures correct accumulation.
|
|
592
|
+
for root in tree.roots:
|
|
593
|
+
self._propagate_tree(root, inclusions_by_child)
|
|
594
|
+
|
|
595
|
+
self._tree = tree
|
|
596
|
+
return tree
|
|
597
|
+
|
|
598
|
+
def _resolve_parent(self, inclusion: RouterInclusion) -> str | None:
|
|
599
|
+
"""Resolve the parent router's qualified name."""
|
|
600
|
+
file_path = inclusion.file_path
|
|
601
|
+
parent_var = inclusion.parent_var
|
|
602
|
+
|
|
603
|
+
# Check local mappings first
|
|
604
|
+
if file_path in self._var_to_qualified:
|
|
605
|
+
if parent_var in self._var_to_qualified[file_path]:
|
|
606
|
+
return self._var_to_qualified[file_path][parent_var]
|
|
607
|
+
|
|
608
|
+
# Try as qualified name directly
|
|
609
|
+
if parent_var in self._routers:
|
|
610
|
+
return parent_var
|
|
611
|
+
|
|
612
|
+
return None
|
|
613
|
+
|
|
614
|
+
def _join_paths(self, *parts: str) -> str:
|
|
615
|
+
"""Join path parts, handling slashes correctly."""
|
|
616
|
+
result = ""
|
|
617
|
+
for part in parts:
|
|
618
|
+
if not part:
|
|
619
|
+
continue
|
|
620
|
+
part = part.strip("/")
|
|
621
|
+
if not part:
|
|
622
|
+
continue
|
|
623
|
+
result = f"{result}/{part}" if result else part
|
|
624
|
+
return f"/{result}" if result else "/"
|
|
625
|
+
|
|
626
|
+
@staticmethod
|
|
627
|
+
def _parse_depends_names(raw_deps: list[str]) -> list[str]:
|
|
628
|
+
"""Extract function names from ``Depends(func)`` expressions.
|
|
629
|
+
|
|
630
|
+
Handles raw strings like ``"Depends(get_current_user)"`` as well as
|
|
631
|
+
plain function names.
|
|
632
|
+
"""
|
|
633
|
+
names: list[str] = []
|
|
634
|
+
for dep in raw_deps:
|
|
635
|
+
match = re.match(r"Depends\s*\(\s*(\w[\w.]*)\s*\)", dep)
|
|
636
|
+
if match:
|
|
637
|
+
names.append(match.group(1))
|
|
638
|
+
elif dep and re.match(r"^[\w.]+$", dep):
|
|
639
|
+
names.append(dep)
|
|
640
|
+
return names
|
|
641
|
+
|
|
642
|
+
def _propagate_tree(
|
|
643
|
+
self,
|
|
644
|
+
node: RouterNode,
|
|
645
|
+
inclusions_by_child: dict[str, RouterInclusion],
|
|
646
|
+
) -> None:
|
|
647
|
+
"""Recursively recompute prefixes and dependencies top-down.
|
|
648
|
+
|
|
649
|
+
This ensures deeply nested routers get the full accumulated state
|
|
650
|
+
from all ancestors, regardless of the order inclusions were processed.
|
|
651
|
+
"""
|
|
652
|
+
for child in node.children:
|
|
653
|
+
child_q = child.definition.qualified_name
|
|
654
|
+
inclusion = inclusions_by_child.get(child_q)
|
|
655
|
+
|
|
656
|
+
# Recompute prefix from the (now-stable) parent
|
|
657
|
+
child.computed_prefix = self._join_paths(
|
|
658
|
+
node.computed_prefix,
|
|
659
|
+
inclusion.prefix if inclusion else "",
|
|
660
|
+
child.definition.prefix,
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
# Recompute tags
|
|
664
|
+
child.computed_tags = (
|
|
665
|
+
list(node.computed_tags)
|
|
666
|
+
+ (inclusion.tags if inclusion else [])
|
|
667
|
+
+ list(child.definition.tags)
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
# Recompute dependencies: parent's accumulated + inclusion-level + constructor-level
|
|
671
|
+
inclusion_dep_names = (
|
|
672
|
+
self._parse_depends_names(inclusion.dependencies) if inclusion else []
|
|
673
|
+
)
|
|
674
|
+
constructor_dep_names = self._parse_depends_names(
|
|
675
|
+
child.definition.constructor_dependencies
|
|
676
|
+
)
|
|
677
|
+
child.computed_dependencies = (
|
|
678
|
+
list(node.computed_dependencies) + inclusion_dep_names + constructor_dep_names
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
self._propagate_tree(child, inclusions_by_child)
|
|
682
|
+
|
|
683
|
+
# =========================================================================
|
|
684
|
+
# Query Methods
|
|
685
|
+
# =========================================================================
|
|
686
|
+
|
|
687
|
+
def resolve_path(
|
|
688
|
+
self,
|
|
689
|
+
router_var: str,
|
|
690
|
+
route_path: str,
|
|
691
|
+
file_path: Path,
|
|
692
|
+
) -> str:
|
|
693
|
+
"""
|
|
694
|
+
Resolve a route path to its final URL.
|
|
695
|
+
|
|
696
|
+
Args:
|
|
697
|
+
router_var: The router variable name (e.g., "router", "app")
|
|
698
|
+
route_path: The path from the decorator (e.g., "/users")
|
|
699
|
+
file_path: The file where the route is defined
|
|
700
|
+
|
|
701
|
+
Returns:
|
|
702
|
+
The fully resolved path with all prefixes applied
|
|
703
|
+
"""
|
|
704
|
+
if not self._tree:
|
|
705
|
+
self.build_tree()
|
|
706
|
+
|
|
707
|
+
# Find the router
|
|
708
|
+
qualified = None
|
|
709
|
+
if file_path in self._var_to_qualified:
|
|
710
|
+
qualified = self._var_to_qualified[file_path].get(router_var)
|
|
711
|
+
|
|
712
|
+
if not qualified:
|
|
713
|
+
# Return path as-is
|
|
714
|
+
return route_path
|
|
715
|
+
|
|
716
|
+
# Find the node
|
|
717
|
+
for root in self._tree.roots:
|
|
718
|
+
node = self._find_node(root, qualified)
|
|
719
|
+
if node:
|
|
720
|
+
return self._join_paths(node.computed_prefix, route_path)
|
|
721
|
+
|
|
722
|
+
return route_path
|
|
723
|
+
|
|
724
|
+
def _find_node(self, node: RouterNode, qualified: str) -> RouterNode | None:
|
|
725
|
+
"""Find a node by qualified name."""
|
|
726
|
+
if node.definition.qualified_name == qualified:
|
|
727
|
+
return node
|
|
728
|
+
|
|
729
|
+
for child in node.children:
|
|
730
|
+
found = self._find_node(child, qualified)
|
|
731
|
+
if found:
|
|
732
|
+
return found
|
|
733
|
+
|
|
734
|
+
return None
|
|
735
|
+
|
|
736
|
+
def get_router_dependencies(
|
|
737
|
+
self,
|
|
738
|
+
router_var: str,
|
|
739
|
+
file_path: Path,
|
|
740
|
+
) -> list[str]:
|
|
741
|
+
"""Return the accumulated dependency names for a router.
|
|
742
|
+
|
|
743
|
+
These are the dependency function names declared via
|
|
744
|
+
``dependencies=[Depends(...)]`` on ``include_router()`` calls in the
|
|
745
|
+
ancestry chain. They apply to every route under this router.
|
|
746
|
+
"""
|
|
747
|
+
if not self._tree:
|
|
748
|
+
self.build_tree()
|
|
749
|
+
|
|
750
|
+
qualified = None
|
|
751
|
+
if file_path in self._var_to_qualified:
|
|
752
|
+
qualified = self._var_to_qualified[file_path].get(router_var)
|
|
753
|
+
|
|
754
|
+
if not qualified:
|
|
755
|
+
return []
|
|
756
|
+
|
|
757
|
+
for root in self._tree.roots:
|
|
758
|
+
node = self._find_node(root, qualified)
|
|
759
|
+
if node:
|
|
760
|
+
return list(node.computed_dependencies)
|
|
761
|
+
|
|
762
|
+
return []
|
|
763
|
+
|
|
764
|
+
def get_router_by_var(self, var_name: str, file_path: Path) -> RouterDefinition | None:
|
|
765
|
+
"""Get router definition by variable name in a file."""
|
|
766
|
+
if file_path in self._var_to_qualified:
|
|
767
|
+
qualified = self._var_to_qualified[file_path].get(var_name)
|
|
768
|
+
if qualified:
|
|
769
|
+
return self._routers.get(qualified)
|
|
770
|
+
return None
|
|
771
|
+
|
|
772
|
+
def get_all_routers(self) -> dict[str, RouterDefinition]:
|
|
773
|
+
"""Get all registered routers."""
|
|
774
|
+
return dict(self._routers)
|
|
775
|
+
|
|
776
|
+
def get_root_apps(self) -> list[RouterDefinition]:
|
|
777
|
+
"""Get all root FastAPI/Starlette applications."""
|
|
778
|
+
return [r for r in self._routers.values() if r.router_type in {"FastAPI", "Starlette"}]
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
# =============================================================================
|
|
782
|
+
# Convenience Functions
|
|
783
|
+
# =============================================================================
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def build_router_registry(
|
|
787
|
+
parsed_files: list[ParsedFile],
|
|
788
|
+
project_root: Path | None = None,
|
|
789
|
+
) -> RouterRegistry:
|
|
790
|
+
"""
|
|
791
|
+
Build a router registry from parsed files.
|
|
792
|
+
|
|
793
|
+
Args:
|
|
794
|
+
parsed_files: List of successfully parsed files
|
|
795
|
+
project_root: Optional project root for path resolution
|
|
796
|
+
|
|
797
|
+
Returns:
|
|
798
|
+
Configured RouterRegistry with tree built
|
|
799
|
+
"""
|
|
800
|
+
registry = RouterRegistry(project_root)
|
|
801
|
+
|
|
802
|
+
for parsed in parsed_files:
|
|
803
|
+
registry.process_file(parsed)
|
|
804
|
+
|
|
805
|
+
registry.build_tree()
|
|
806
|
+
return registry
|