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,532 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dynamic route detection for FastAPI/Starlette applications.
|
|
3
|
+
|
|
4
|
+
This module handles routes that are registered programmatically rather than
|
|
5
|
+
via decorators:
|
|
6
|
+
|
|
7
|
+
1. Direct registration: app.add_api_route("/path", handler, methods=["GET"])
|
|
8
|
+
2. Factory functions: create_crud_routes(model=User) returning routes
|
|
9
|
+
3. Loop-based registration: for r in routes: app.add_api_route(...)
|
|
10
|
+
4. Route tables: routes = [Route("/path", handler, methods=["GET"])]
|
|
11
|
+
|
|
12
|
+
CRITICAL: Enterprise applications often use these patterns for:
|
|
13
|
+
- CRUD generation (SQLAlchemy-Admin, FastAPI-CRUD)
|
|
14
|
+
- Dynamic plugin systems
|
|
15
|
+
- Multi-tenant routing
|
|
16
|
+
- Generic REST resource creation
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import TYPE_CHECKING, Any
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from ..base import ParsedFile, ParsedFunction
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# =============================================================================
|
|
31
|
+
# Data Types
|
|
32
|
+
# =============================================================================
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class DynamicRoute:
|
|
37
|
+
"""A route registered dynamically (not via decorator)."""
|
|
38
|
+
|
|
39
|
+
# Route info (may be partially resolved)
|
|
40
|
+
path: str
|
|
41
|
+
methods: list[str] # ["GET"], ["GET", "POST"], etc.
|
|
42
|
+
|
|
43
|
+
# Handler info
|
|
44
|
+
handler_name: str # Function name or reference
|
|
45
|
+
|
|
46
|
+
# Registration details
|
|
47
|
+
registration_type: str # "add_api_route", "add_route", "route_table", "factory"
|
|
48
|
+
|
|
49
|
+
# Optional fields with defaults
|
|
50
|
+
handler_qualified: str | None = None
|
|
51
|
+
file_path: Path | None = None
|
|
52
|
+
line: int = 0
|
|
53
|
+
|
|
54
|
+
# Resolution status
|
|
55
|
+
is_fully_resolved: bool = False
|
|
56
|
+
confidence: float = 0.5
|
|
57
|
+
|
|
58
|
+
# For factory-generated routes
|
|
59
|
+
factory_function: str | None = None
|
|
60
|
+
factory_arguments: dict[str, Any] = field(default_factory=dict)
|
|
61
|
+
|
|
62
|
+
# Notes about detection
|
|
63
|
+
notes: list[str] = field(default_factory=list)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class RouteFactory:
|
|
68
|
+
"""A function that generates routes."""
|
|
69
|
+
|
|
70
|
+
name: str
|
|
71
|
+
qualified_name: str
|
|
72
|
+
file_path: Path
|
|
73
|
+
line: int
|
|
74
|
+
|
|
75
|
+
# What kind of factory?
|
|
76
|
+
factory_type: str # "crud", "resource", "generic", "unknown"
|
|
77
|
+
|
|
78
|
+
# Parameters that affect route generation
|
|
79
|
+
model_param: str | None = None # Parameter name for model class
|
|
80
|
+
prefix_param: str | None = None # Parameter name for path prefix
|
|
81
|
+
|
|
82
|
+
# Detected route patterns
|
|
83
|
+
generated_routes: list[DynamicRoute] = field(default_factory=list)
|
|
84
|
+
|
|
85
|
+
# Confidence in detection
|
|
86
|
+
confidence: float = 0.5
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class LoopRouteRegistration:
|
|
91
|
+
"""Route registration happening in a loop."""
|
|
92
|
+
|
|
93
|
+
# Loop info
|
|
94
|
+
file_path: Path
|
|
95
|
+
line: int
|
|
96
|
+
|
|
97
|
+
# What's being iterated
|
|
98
|
+
iterable_name: str # e.g., "routes", "endpoints"
|
|
99
|
+
|
|
100
|
+
# Registration call info
|
|
101
|
+
registration_call: str # e.g., "app.add_api_route"
|
|
102
|
+
|
|
103
|
+
# Optional fields with defaults
|
|
104
|
+
iterable_source: str | None = None # Where the iterable comes from
|
|
105
|
+
|
|
106
|
+
# Detected static routes from the iterable
|
|
107
|
+
detected_routes: list[DynamicRoute] = field(default_factory=list)
|
|
108
|
+
|
|
109
|
+
# Notes
|
|
110
|
+
notes: list[str] = field(default_factory=list)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# =============================================================================
|
|
114
|
+
# Dynamic Route Detector
|
|
115
|
+
# =============================================================================
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class DynamicRouteDetector:
|
|
119
|
+
"""
|
|
120
|
+
Detects dynamically registered routes in FastAPI/Starlette applications.
|
|
121
|
+
|
|
122
|
+
This handles cases where routes are not registered via decorators but
|
|
123
|
+
through programmatic calls like:
|
|
124
|
+
|
|
125
|
+
- app.add_api_route("/users", list_users, methods=["GET"])
|
|
126
|
+
- routes = [Route("/users", endpoint=list_users)]
|
|
127
|
+
- for route in crud_routes: app.add_api_route(...)
|
|
128
|
+
|
|
129
|
+
Usage:
|
|
130
|
+
detector = DynamicRouteDetector()
|
|
131
|
+
|
|
132
|
+
# Process files
|
|
133
|
+
for parsed in parsed_files:
|
|
134
|
+
detector.process_file(parsed)
|
|
135
|
+
|
|
136
|
+
# Get detected routes
|
|
137
|
+
routes = detector.get_all_routes()
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
def __init__(self, project_root: Path | None = None):
|
|
141
|
+
"""Initialize the detector."""
|
|
142
|
+
self._project_root = project_root
|
|
143
|
+
self._dynamic_routes: list[DynamicRoute] = []
|
|
144
|
+
self._factories: dict[str, RouteFactory] = {}
|
|
145
|
+
self._loop_registrations: list[LoopRouteRegistration] = []
|
|
146
|
+
self._route_tables: dict[Path, dict[str, list[DynamicRoute]]] = {}
|
|
147
|
+
self._app_vars: dict[Path, set[str]] = {}
|
|
148
|
+
|
|
149
|
+
def process_file(self, parsed: ParsedFile) -> None:
|
|
150
|
+
"""Process a parsed file to detect dynamic routes."""
|
|
151
|
+
if not parsed.success:
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
file_path = parsed.path
|
|
155
|
+
self._app_vars[file_path] = set()
|
|
156
|
+
self._route_tables[file_path] = {}
|
|
157
|
+
|
|
158
|
+
# Step 1: Identify app/router variables
|
|
159
|
+
self._identify_app_routers(parsed)
|
|
160
|
+
|
|
161
|
+
# Step 2: Find add_api_route / add_route calls
|
|
162
|
+
self._detect_add_route_calls(parsed)
|
|
163
|
+
|
|
164
|
+
# Step 3: Find route table definitions
|
|
165
|
+
self._detect_route_tables(parsed)
|
|
166
|
+
|
|
167
|
+
# Step 4: Find factory functions
|
|
168
|
+
self._detect_factory_functions(parsed)
|
|
169
|
+
|
|
170
|
+
# Step 5: Find loop-based registrations
|
|
171
|
+
self._detect_loop_registrations(parsed)
|
|
172
|
+
|
|
173
|
+
def _identify_app_routers(self, parsed: ParsedFile) -> None:
|
|
174
|
+
"""Identify FastAPI/Router variables."""
|
|
175
|
+
file_path = parsed.path
|
|
176
|
+
|
|
177
|
+
for assign in parsed.assignments:
|
|
178
|
+
if assign.source_type == "call":
|
|
179
|
+
called = assign.source_call or ""
|
|
180
|
+
if any(x in called for x in ["FastAPI", "APIRouter", "Starlette", "Router"]):
|
|
181
|
+
self._app_vars[file_path].add(assign.target)
|
|
182
|
+
|
|
183
|
+
def _detect_add_route_calls(self, parsed: ParsedFile) -> None:
|
|
184
|
+
"""Detect add_api_route and add_route calls."""
|
|
185
|
+
file_path = parsed.path
|
|
186
|
+
app_vars = self._app_vars.get(file_path, set())
|
|
187
|
+
|
|
188
|
+
for call in parsed.call_sites:
|
|
189
|
+
callee = call.callee_name
|
|
190
|
+
|
|
191
|
+
# Check for add_api_route or add_route
|
|
192
|
+
is_add_route = False
|
|
193
|
+
if callee.endswith("add_api_route") or callee.endswith("add_route"):
|
|
194
|
+
# Verify it's on an app/router variable
|
|
195
|
+
parts = callee.rsplit(".", 1)
|
|
196
|
+
if len(parts) >= 2:
|
|
197
|
+
var_name = parts[0].split(".")[-1] # Handle chained attrs
|
|
198
|
+
if var_name in app_vars or var_name in {"app", "router", "api"}:
|
|
199
|
+
is_add_route = True
|
|
200
|
+
|
|
201
|
+
if not is_add_route:
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
# Extract route info from call arguments
|
|
205
|
+
route = self._extract_route_from_add_call(call, file_path)
|
|
206
|
+
if route:
|
|
207
|
+
self._dynamic_routes.append(route)
|
|
208
|
+
|
|
209
|
+
def _extract_route_from_add_call(self, call, file_path: Path) -> DynamicRoute | None:
|
|
210
|
+
"""Extract route info from add_api_route call."""
|
|
211
|
+
path = ""
|
|
212
|
+
handler = ""
|
|
213
|
+
methods: list[str] = []
|
|
214
|
+
|
|
215
|
+
for arg in call.arguments:
|
|
216
|
+
# Path is typically first positional arg
|
|
217
|
+
if arg.position == 0 or arg.name == "path":
|
|
218
|
+
path = self._extract_arg_value(arg)
|
|
219
|
+
|
|
220
|
+
# Handler is second positional or 'endpoint'
|
|
221
|
+
elif arg.position == 1 or arg.name == "endpoint":
|
|
222
|
+
handler = arg.variable_name or self._extract_arg_value(arg)
|
|
223
|
+
|
|
224
|
+
# Methods
|
|
225
|
+
elif arg.name == "methods":
|
|
226
|
+
methods = self._extract_methods(arg)
|
|
227
|
+
|
|
228
|
+
if not path:
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
# Default method is GET
|
|
232
|
+
if not methods:
|
|
233
|
+
methods = ["GET"]
|
|
234
|
+
|
|
235
|
+
return DynamicRoute(
|
|
236
|
+
path=path,
|
|
237
|
+
methods=methods,
|
|
238
|
+
handler_name=handler,
|
|
239
|
+
registration_type="add_api_route",
|
|
240
|
+
file_path=file_path,
|
|
241
|
+
line=call.location.line,
|
|
242
|
+
is_fully_resolved=not self._has_variable_ref(path),
|
|
243
|
+
confidence=0.9 if not self._has_variable_ref(path) else 0.6,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def _detect_route_tables(self, parsed: ParsedFile) -> None:
|
|
247
|
+
"""Detect route table assignments like routes = [Route(...), ...]."""
|
|
248
|
+
file_path = parsed.path
|
|
249
|
+
|
|
250
|
+
for assign in parsed.assignments:
|
|
251
|
+
# Look for list assignments with Route() calls
|
|
252
|
+
source = assign.value_source or ""
|
|
253
|
+
|
|
254
|
+
# Check for Route or APIRoute in a list
|
|
255
|
+
if not ("[" in source and "Route(" in source):
|
|
256
|
+
continue
|
|
257
|
+
|
|
258
|
+
routes = self._parse_route_table(source, file_path, assign.location.line)
|
|
259
|
+
if routes:
|
|
260
|
+
self._route_tables[file_path][assign.target] = routes
|
|
261
|
+
self._dynamic_routes.extend(routes)
|
|
262
|
+
|
|
263
|
+
def _parse_route_table(
|
|
264
|
+
self,
|
|
265
|
+
source: str,
|
|
266
|
+
file_path: Path,
|
|
267
|
+
line: int,
|
|
268
|
+
) -> list[DynamicRoute]:
|
|
269
|
+
"""Parse a route table definition."""
|
|
270
|
+
routes: list[DynamicRoute] = []
|
|
271
|
+
|
|
272
|
+
# Pattern for Route("/path", endpoint=handler, methods=["GET"])
|
|
273
|
+
route_pattern = r'(?:API)?Route\s*\(\s*["\']([^"\']+)["\']\s*(?:,\s*(?:endpoint\s*=\s*)?(\w+))?\s*(?:,\s*methods\s*=\s*\[([^\]]*)\])?'
|
|
274
|
+
|
|
275
|
+
for match in re.finditer(route_pattern, source):
|
|
276
|
+
path = match.group(1)
|
|
277
|
+
handler = match.group(2) or ""
|
|
278
|
+
methods_str = match.group(3) or ""
|
|
279
|
+
|
|
280
|
+
# Parse methods
|
|
281
|
+
methods = re.findall(r'["\'](\w+)["\']', methods_str) or ["GET"]
|
|
282
|
+
|
|
283
|
+
routes.append(
|
|
284
|
+
DynamicRoute(
|
|
285
|
+
path=path,
|
|
286
|
+
methods=[m.upper() for m in methods],
|
|
287
|
+
handler_name=handler,
|
|
288
|
+
registration_type="route_table",
|
|
289
|
+
file_path=file_path,
|
|
290
|
+
line=line,
|
|
291
|
+
is_fully_resolved=True,
|
|
292
|
+
confidence=0.95,
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
return routes
|
|
297
|
+
|
|
298
|
+
def _detect_factory_functions(self, parsed: ParsedFile) -> None:
|
|
299
|
+
"""Detect functions that generate routes."""
|
|
300
|
+
|
|
301
|
+
for func in parsed.functions:
|
|
302
|
+
factory = self._analyze_factory_function(func, parsed)
|
|
303
|
+
if factory:
|
|
304
|
+
self._factories[factory.qualified_name] = factory
|
|
305
|
+
|
|
306
|
+
def _analyze_factory_function(
|
|
307
|
+
self,
|
|
308
|
+
func: ParsedFunction,
|
|
309
|
+
parsed: ParsedFile,
|
|
310
|
+
) -> RouteFactory | None:
|
|
311
|
+
"""Analyze a function to determine if it's a route factory."""
|
|
312
|
+
file_path = parsed.path
|
|
313
|
+
name = func.name.lower()
|
|
314
|
+
|
|
315
|
+
# Check naming patterns
|
|
316
|
+
factory_patterns = [
|
|
317
|
+
"create_routes",
|
|
318
|
+
"create_crud",
|
|
319
|
+
"generate_routes",
|
|
320
|
+
"make_routes",
|
|
321
|
+
"crud_routes",
|
|
322
|
+
"resource_routes",
|
|
323
|
+
"register_routes",
|
|
324
|
+
"build_routes",
|
|
325
|
+
"create_api",
|
|
326
|
+
"crud_generator",
|
|
327
|
+
"crud_factory",
|
|
328
|
+
]
|
|
329
|
+
|
|
330
|
+
is_likely_factory = False
|
|
331
|
+
factory_type = "unknown"
|
|
332
|
+
|
|
333
|
+
for pattern in factory_patterns:
|
|
334
|
+
if pattern in name:
|
|
335
|
+
is_likely_factory = True
|
|
336
|
+
if "crud" in pattern:
|
|
337
|
+
factory_type = "crud"
|
|
338
|
+
elif "resource" in pattern:
|
|
339
|
+
factory_type = "resource"
|
|
340
|
+
else:
|
|
341
|
+
factory_type = "generic"
|
|
342
|
+
break
|
|
343
|
+
|
|
344
|
+
# Check return type
|
|
345
|
+
if func.return_type:
|
|
346
|
+
ret_lower = func.return_type.lower()
|
|
347
|
+
if any(x in ret_lower for x in ["router", "apirouter", "list[route"]):
|
|
348
|
+
is_likely_factory = True
|
|
349
|
+
|
|
350
|
+
if not is_likely_factory:
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
# Analyze parameters
|
|
354
|
+
model_param = None
|
|
355
|
+
prefix_param = None
|
|
356
|
+
|
|
357
|
+
for param in func.parameters:
|
|
358
|
+
param_name = param.name.lower()
|
|
359
|
+
if param_name in {"model", "model_class", "schema", "orm_model"}:
|
|
360
|
+
model_param = param.name
|
|
361
|
+
elif param_name in {"prefix", "path_prefix", "base_path"}:
|
|
362
|
+
prefix_param = param.name
|
|
363
|
+
|
|
364
|
+
# Try to detect generated routes from function body
|
|
365
|
+
generated_routes = self._detect_routes_in_function(func, parsed)
|
|
366
|
+
|
|
367
|
+
return RouteFactory(
|
|
368
|
+
name=func.name,
|
|
369
|
+
qualified_name=func.qualified_name.full,
|
|
370
|
+
file_path=file_path,
|
|
371
|
+
line=func.location.line,
|
|
372
|
+
factory_type=factory_type,
|
|
373
|
+
model_param=model_param,
|
|
374
|
+
prefix_param=prefix_param,
|
|
375
|
+
generated_routes=generated_routes,
|
|
376
|
+
confidence=0.7 if generated_routes else 0.4,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
def _detect_routes_in_function(
|
|
380
|
+
self,
|
|
381
|
+
func: ParsedFunction,
|
|
382
|
+
parsed: ParsedFile,
|
|
383
|
+
) -> list[DynamicRoute]:
|
|
384
|
+
"""Detect routes defined within a function (factory pattern)."""
|
|
385
|
+
routes: list[DynamicRoute] = []
|
|
386
|
+
file_path = parsed.path
|
|
387
|
+
|
|
388
|
+
# Find decorators within the function that create routes
|
|
389
|
+
# This is tricky because we need to look at nested functions
|
|
390
|
+
|
|
391
|
+
# For now, look for common CRUD patterns
|
|
392
|
+
crud_methods = [
|
|
393
|
+
("", ["GET"], "list"),
|
|
394
|
+
("/{id}", ["GET"], "read"),
|
|
395
|
+
("", ["POST"], "create"),
|
|
396
|
+
("/{id}", ["PUT", "PATCH"], "update"),
|
|
397
|
+
("/{id}", ["DELETE"], "delete"),
|
|
398
|
+
]
|
|
399
|
+
|
|
400
|
+
# Check if function body contains these patterns
|
|
401
|
+
if func.docstring and "crud" in func.docstring.lower():
|
|
402
|
+
for path_suffix, methods, operation in crud_methods:
|
|
403
|
+
routes.append(
|
|
404
|
+
DynamicRoute(
|
|
405
|
+
path=f"{{prefix}}{path_suffix}",
|
|
406
|
+
methods=methods,
|
|
407
|
+
handler_name=f"{func.name}_{operation}",
|
|
408
|
+
registration_type="factory",
|
|
409
|
+
file_path=file_path,
|
|
410
|
+
line=func.location.line,
|
|
411
|
+
factory_function=func.name,
|
|
412
|
+
is_fully_resolved=False,
|
|
413
|
+
confidence=0.5,
|
|
414
|
+
notes=[f"Inferred from CRUD factory: {func.name}"],
|
|
415
|
+
)
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
return routes
|
|
419
|
+
|
|
420
|
+
def _detect_loop_registrations(self, parsed: ParsedFile) -> None:
|
|
421
|
+
"""Detect loop-based route registrations."""
|
|
422
|
+
file_path = parsed.path
|
|
423
|
+
|
|
424
|
+
# This requires more sophisticated analysis of control flow
|
|
425
|
+
# For now, we look for patterns in call sites
|
|
426
|
+
|
|
427
|
+
for call in parsed.call_sites:
|
|
428
|
+
if not (
|
|
429
|
+
call.callee_name.endswith("add_api_route") or call.callee_name.endswith("add_route")
|
|
430
|
+
):
|
|
431
|
+
continue
|
|
432
|
+
|
|
433
|
+
# Check if any argument references a loop variable
|
|
434
|
+
# This is a heuristic - we check for common patterns
|
|
435
|
+
for arg in call.arguments:
|
|
436
|
+
expr = arg.expression_text or ""
|
|
437
|
+
|
|
438
|
+
# Patterns like: route["path"], route.path, item["endpoint"]
|
|
439
|
+
if re.search(r"\w+\[.+\]|\w+\.\w+", expr):
|
|
440
|
+
loop_reg = LoopRouteRegistration(
|
|
441
|
+
file_path=file_path,
|
|
442
|
+
line=call.location.line,
|
|
443
|
+
iterable_name="routes", # Inferred
|
|
444
|
+
registration_call=call.callee_name,
|
|
445
|
+
notes=["Detected dictionary/attribute access in route registration"],
|
|
446
|
+
)
|
|
447
|
+
self._loop_registrations.append(loop_reg)
|
|
448
|
+
break
|
|
449
|
+
|
|
450
|
+
def _extract_arg_value(self, arg) -> str:
|
|
451
|
+
"""Extract value from a call argument."""
|
|
452
|
+
if arg.literal_value is not None:
|
|
453
|
+
return str(arg.literal_value)
|
|
454
|
+
if arg.expression_text:
|
|
455
|
+
# Try to extract string literal
|
|
456
|
+
match = re.search(r'["\']([^"\']*)["\']', arg.expression_text)
|
|
457
|
+
if match:
|
|
458
|
+
return match.group(1)
|
|
459
|
+
return arg.expression_text
|
|
460
|
+
return ""
|
|
461
|
+
|
|
462
|
+
def _extract_methods(self, arg) -> list[str]:
|
|
463
|
+
"""Extract HTTP methods from argument."""
|
|
464
|
+
methods = []
|
|
465
|
+
|
|
466
|
+
if isinstance(arg.literal_value, list):
|
|
467
|
+
return [str(m).upper() for m in arg.literal_value]
|
|
468
|
+
|
|
469
|
+
if arg.expression_text:
|
|
470
|
+
matches = re.findall(r'["\'](\w+)["\']', arg.expression_text)
|
|
471
|
+
methods = [m.upper() for m in matches]
|
|
472
|
+
|
|
473
|
+
return methods or ["GET"]
|
|
474
|
+
|
|
475
|
+
def _has_variable_ref(self, value: str) -> bool:
|
|
476
|
+
"""Check if value contains unresolved variable references."""
|
|
477
|
+
# f-string variables: {var}
|
|
478
|
+
if re.search(r"\{[^}]+\}", value):
|
|
479
|
+
return True
|
|
480
|
+
# Not a literal
|
|
481
|
+
return bool(not (value.startswith("/") or value.startswith('"') or value.startswith("'")))
|
|
482
|
+
|
|
483
|
+
# =========================================================================
|
|
484
|
+
# Query Methods
|
|
485
|
+
# =========================================================================
|
|
486
|
+
|
|
487
|
+
def get_all_routes(self) -> list[DynamicRoute]:
|
|
488
|
+
"""Get all detected dynamic routes."""
|
|
489
|
+
return list(self._dynamic_routes)
|
|
490
|
+
|
|
491
|
+
def get_routes_for_file(self, file_path: Path) -> list[DynamicRoute]:
|
|
492
|
+
"""Get dynamic routes defined in a specific file."""
|
|
493
|
+
return [r for r in self._dynamic_routes if r.file_path == file_path]
|
|
494
|
+
|
|
495
|
+
def get_factories(self) -> dict[str, RouteFactory]:
|
|
496
|
+
"""Get all detected route factories."""
|
|
497
|
+
return dict(self._factories)
|
|
498
|
+
|
|
499
|
+
def get_loop_registrations(self) -> list[LoopRouteRegistration]:
|
|
500
|
+
"""Get all detected loop-based registrations."""
|
|
501
|
+
return list(self._loop_registrations)
|
|
502
|
+
|
|
503
|
+
def get_route_tables(self, file_path: Path) -> dict[str, list[DynamicRoute]]:
|
|
504
|
+
"""Get route tables defined in a file."""
|
|
505
|
+
return self._route_tables.get(file_path, {})
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# =============================================================================
|
|
509
|
+
# Convenience Functions
|
|
510
|
+
# =============================================================================
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def detect_dynamic_routes(
|
|
514
|
+
parsed_files: list[ParsedFile],
|
|
515
|
+
project_root: Path | None = None,
|
|
516
|
+
) -> tuple[list[DynamicRoute], dict[str, RouteFactory]]:
|
|
517
|
+
"""
|
|
518
|
+
Detect all dynamic routes in a project.
|
|
519
|
+
|
|
520
|
+
Args:
|
|
521
|
+
parsed_files: List of successfully parsed files
|
|
522
|
+
project_root: Optional project root
|
|
523
|
+
|
|
524
|
+
Returns:
|
|
525
|
+
Tuple of (dynamic_routes, factories)
|
|
526
|
+
"""
|
|
527
|
+
detector = DynamicRouteDetector(project_root)
|
|
528
|
+
|
|
529
|
+
for parsed in parsed_files:
|
|
530
|
+
detector.process_file(parsed)
|
|
531
|
+
|
|
532
|
+
return detector.get_all_routes(), detector.get_factories()
|