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,789 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Parameter analysis for Python function signatures.
|
|
3
|
+
|
|
4
|
+
This module provides robust analysis of function parameters to determine:
|
|
5
|
+
- Parameter location (path, query, body, header, cookie)
|
|
6
|
+
- Type information and constraints
|
|
7
|
+
- Default value semantics
|
|
8
|
+
- Dependency injection patterns
|
|
9
|
+
|
|
10
|
+
CRITICAL: This replaces fragile string-based detection with proper CST analysis.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
import libcst as cst
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from ..base import ParsedParameter
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# =============================================================================
|
|
26
|
+
# Parameter Analysis Results
|
|
27
|
+
# =============================================================================
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class AnalyzedParameter:
|
|
32
|
+
"""
|
|
33
|
+
Result of analyzing a function parameter.
|
|
34
|
+
|
|
35
|
+
Contains semantic understanding of what the parameter represents
|
|
36
|
+
in the context of an HTTP handler.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
|
|
41
|
+
# Determined location
|
|
42
|
+
location: (
|
|
43
|
+
str # "path", "query", "body", "header", "cookie", "form", "file", "dependency", "unknown"
|
|
44
|
+
)
|
|
45
|
+
location_confidence: float # 0.0 to 1.0
|
|
46
|
+
|
|
47
|
+
# Type information
|
|
48
|
+
type_annotation: str | None = None
|
|
49
|
+
base_type: str | None = None # Unwrapped from Optional, Annotated, etc.
|
|
50
|
+
is_optional: bool = False
|
|
51
|
+
is_list: bool = False
|
|
52
|
+
|
|
53
|
+
# Default value analysis
|
|
54
|
+
has_default: bool = False
|
|
55
|
+
default_value: str | None = None
|
|
56
|
+
default_is_dependency: bool = False # Depends(...)
|
|
57
|
+
default_is_marker: bool = False # Query(...), Path(...), etc.
|
|
58
|
+
|
|
59
|
+
# Marker details (when default_is_marker=True)
|
|
60
|
+
marker_type: str | None = None # "Query", "Path", "Body", etc.
|
|
61
|
+
marker_args: dict[str, Any] = field(default_factory=dict)
|
|
62
|
+
|
|
63
|
+
# Dependency details (when default_is_dependency=True)
|
|
64
|
+
dependency_function: str | None = None
|
|
65
|
+
|
|
66
|
+
# Validation constraints extracted from marker
|
|
67
|
+
constraints: dict[str, Any] = field(default_factory=dict)
|
|
68
|
+
|
|
69
|
+
# Analysis notes
|
|
70
|
+
notes: list[str] = field(default_factory=list)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class RoutePathAnalysis:
|
|
75
|
+
"""Analysis of a route path for parameter extraction."""
|
|
76
|
+
|
|
77
|
+
path: str
|
|
78
|
+
path_params: list[str] = field(default_factory=list) # Names of {param} in path
|
|
79
|
+
has_path_params: bool = False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# =============================================================================
|
|
83
|
+
# Constants
|
|
84
|
+
# =============================================================================
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# FastAPI/Starlette parameter marker functions
|
|
88
|
+
FASTAPI_MARKERS = {
|
|
89
|
+
"Path": "path",
|
|
90
|
+
"Query": "query",
|
|
91
|
+
"Header": "header",
|
|
92
|
+
"Cookie": "cookie",
|
|
93
|
+
"Body": "body",
|
|
94
|
+
"Form": "form",
|
|
95
|
+
"File": "body", # Files are body content
|
|
96
|
+
"UploadFile": "body",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# Import aliases we should recognize
|
|
100
|
+
FASTAPI_MARKER_MODULES = frozenset(
|
|
101
|
+
{
|
|
102
|
+
"fastapi",
|
|
103
|
+
"fastapi.param_functions",
|
|
104
|
+
"starlette",
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Dependency injection markers
|
|
109
|
+
DEPENDENCY_MARKERS = frozenset({"Depends", "Security"})
|
|
110
|
+
|
|
111
|
+
# Known model base classes (for body detection)
|
|
112
|
+
BODY_MODEL_BASES = frozenset(
|
|
113
|
+
{
|
|
114
|
+
"BaseModel",
|
|
115
|
+
"BaseSettings",
|
|
116
|
+
"pydantic.BaseModel",
|
|
117
|
+
"pydantic.BaseSettings",
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Special parameters to skip
|
|
122
|
+
SKIP_PARAMS = frozenset({"self", "cls", "request", "response", "websocket", "background_tasks"})
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# =============================================================================
|
|
126
|
+
# Path Parameter Extractor
|
|
127
|
+
# =============================================================================
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def extract_path_params(route_path: str) -> RoutePathAnalysis:
|
|
131
|
+
"""
|
|
132
|
+
Extract path parameters from a route path.
|
|
133
|
+
|
|
134
|
+
Handles:
|
|
135
|
+
- Simple params: /users/{user_id}
|
|
136
|
+
- Typed params: /users/{user_id:int}
|
|
137
|
+
- Multiple params: /users/{user_id}/posts/{post_id}
|
|
138
|
+
- Path params: /files/{file_path:path}
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
route_path: The route path string (e.g., "/users/{user_id}")
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
RoutePathAnalysis with extracted parameter names
|
|
145
|
+
"""
|
|
146
|
+
# Pattern to match {param} or {param:type}
|
|
147
|
+
pattern = r"\{([^}:]+)(?::[^}]+)?\}"
|
|
148
|
+
|
|
149
|
+
matches = re.findall(pattern, route_path)
|
|
150
|
+
|
|
151
|
+
return RoutePathAnalysis(
|
|
152
|
+
path=route_path,
|
|
153
|
+
path_params=matches,
|
|
154
|
+
has_path_params=len(matches) > 0,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# =============================================================================
|
|
159
|
+
# Default Value Analyzer
|
|
160
|
+
# =============================================================================
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class DefaultValueAnalyzer:
|
|
164
|
+
"""
|
|
165
|
+
Analyzes default value expressions to determine parameter semantics.
|
|
166
|
+
|
|
167
|
+
This parses the default value AST to properly identify:
|
|
168
|
+
- Depends(func) patterns
|
|
169
|
+
- Query(...), Path(...), etc. markers
|
|
170
|
+
- Literal defaults
|
|
171
|
+
- Complex expressions
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
def __init__(self, import_aliases: dict[str, str] | None = None):
|
|
175
|
+
"""
|
|
176
|
+
Initialize analyzer with import context.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
import_aliases: Mapping of local names to their original imports.
|
|
180
|
+
e.g., {"Q": "Query"} if `from fastapi import Query as Q`
|
|
181
|
+
"""
|
|
182
|
+
self._aliases = import_aliases or {}
|
|
183
|
+
|
|
184
|
+
def analyze(self, default_value: str | None) -> dict[str, Any]:
|
|
185
|
+
"""
|
|
186
|
+
Analyze a default value string.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
default_value: The default value source code string
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Dict with analysis results:
|
|
193
|
+
- is_marker: bool
|
|
194
|
+
- marker_type: str | None
|
|
195
|
+
- marker_args: dict
|
|
196
|
+
- is_dependency: bool
|
|
197
|
+
- dependency_function: str | None
|
|
198
|
+
- is_literal: bool
|
|
199
|
+
- literal_value: Any
|
|
200
|
+
"""
|
|
201
|
+
if not default_value:
|
|
202
|
+
return {"is_marker": False, "is_dependency": False, "is_literal": False}
|
|
203
|
+
|
|
204
|
+
default_value = default_value.strip()
|
|
205
|
+
|
|
206
|
+
# Try to parse as CST
|
|
207
|
+
try:
|
|
208
|
+
expr = cst.parse_expression(default_value)
|
|
209
|
+
return self._analyze_expression(expr)
|
|
210
|
+
except Exception:
|
|
211
|
+
# Fallback to pattern matching
|
|
212
|
+
return self._analyze_fallback(default_value)
|
|
213
|
+
|
|
214
|
+
def _analyze_expression(self, expr: cst.BaseExpression) -> dict[str, Any]:
|
|
215
|
+
"""Analyze a parsed CST expression."""
|
|
216
|
+
result = {
|
|
217
|
+
"is_marker": False,
|
|
218
|
+
"marker_type": None,
|
|
219
|
+
"marker_args": {},
|
|
220
|
+
"is_dependency": False,
|
|
221
|
+
"dependency_function": None,
|
|
222
|
+
"is_literal": False,
|
|
223
|
+
"literal_value": None,
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
# Check if it's a function call
|
|
227
|
+
if isinstance(expr, cst.Call):
|
|
228
|
+
func_name = self._get_call_name(expr.func)
|
|
229
|
+
|
|
230
|
+
# Resolve alias
|
|
231
|
+
resolved_name = self._aliases.get(func_name, func_name)
|
|
232
|
+
|
|
233
|
+
# Check for dependency markers
|
|
234
|
+
if resolved_name in DEPENDENCY_MARKERS:
|
|
235
|
+
result["is_dependency"] = True
|
|
236
|
+
# Extract dependency function from first argument
|
|
237
|
+
if expr.args:
|
|
238
|
+
first_arg = expr.args[0]
|
|
239
|
+
result["dependency_function"] = self._expr_to_string(first_arg.value)
|
|
240
|
+
|
|
241
|
+
# Check for FastAPI parameter markers
|
|
242
|
+
elif resolved_name in FASTAPI_MARKERS:
|
|
243
|
+
result["is_marker"] = True
|
|
244
|
+
result["marker_type"] = resolved_name
|
|
245
|
+
result["marker_args"] = self._extract_call_kwargs(expr)
|
|
246
|
+
|
|
247
|
+
# Check for common aliases
|
|
248
|
+
elif func_name.split(".")[-1] in FASTAPI_MARKERS:
|
|
249
|
+
actual_name = func_name.split(".")[-1]
|
|
250
|
+
result["is_marker"] = True
|
|
251
|
+
result["marker_type"] = actual_name
|
|
252
|
+
result["marker_args"] = self._extract_call_kwargs(expr)
|
|
253
|
+
|
|
254
|
+
# Check for literals
|
|
255
|
+
elif isinstance(expr, (cst.Integer, cst.Float, cst.SimpleString)):
|
|
256
|
+
result["is_literal"] = True
|
|
257
|
+
result["literal_value"] = self._extract_literal_value(expr)
|
|
258
|
+
|
|
259
|
+
elif isinstance(expr, cst.Name):
|
|
260
|
+
if expr.value in {"None", "True", "False"}:
|
|
261
|
+
result["is_literal"] = True
|
|
262
|
+
result["literal_value"] = {"None": None, "True": True, "False": False}.get(
|
|
263
|
+
expr.value
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
return result
|
|
267
|
+
|
|
268
|
+
def _get_call_name(self, func: cst.BaseExpression) -> str:
|
|
269
|
+
"""Get the name of a called function."""
|
|
270
|
+
if isinstance(func, cst.Name):
|
|
271
|
+
return func.value
|
|
272
|
+
elif isinstance(func, cst.Attribute):
|
|
273
|
+
# Handle chained attributes like fastapi.Query
|
|
274
|
+
parts = []
|
|
275
|
+
current = func
|
|
276
|
+
while isinstance(current, cst.Attribute):
|
|
277
|
+
parts.append(current.attr.value)
|
|
278
|
+
current = current.value
|
|
279
|
+
if isinstance(current, cst.Name):
|
|
280
|
+
parts.append(current.value)
|
|
281
|
+
parts.reverse()
|
|
282
|
+
return ".".join(parts)
|
|
283
|
+
return ""
|
|
284
|
+
|
|
285
|
+
def _extract_call_kwargs(self, call: cst.Call) -> dict[str, Any]:
|
|
286
|
+
"""Extract keyword arguments from a call."""
|
|
287
|
+
kwargs = {}
|
|
288
|
+
|
|
289
|
+
for i, arg in enumerate(call.args):
|
|
290
|
+
if arg.keyword:
|
|
291
|
+
key = arg.keyword.value
|
|
292
|
+
kwargs[key] = self._expr_to_string(arg.value)
|
|
293
|
+
elif i == 0 and isinstance(arg.value, (cst.SimpleString, cst.ConcatenatedString)):
|
|
294
|
+
# First positional arg might be description
|
|
295
|
+
kwargs["_positional_0"] = self._extract_string_value(arg.value)
|
|
296
|
+
|
|
297
|
+
return kwargs
|
|
298
|
+
|
|
299
|
+
def _expr_to_string(self, expr: cst.BaseExpression) -> str:
|
|
300
|
+
"""Convert expression back to source string."""
|
|
301
|
+
try:
|
|
302
|
+
module = cst.parse_module("")
|
|
303
|
+
return module.code_for_node(expr)
|
|
304
|
+
except Exception:
|
|
305
|
+
return str(expr)
|
|
306
|
+
|
|
307
|
+
def _extract_literal_value(self, node: cst.BaseExpression) -> Any:
|
|
308
|
+
"""Extract literal value from a node."""
|
|
309
|
+
if isinstance(node, cst.Integer):
|
|
310
|
+
return int(node.value)
|
|
311
|
+
elif isinstance(node, cst.Float):
|
|
312
|
+
return float(node.value)
|
|
313
|
+
elif isinstance(node, (cst.SimpleString, cst.ConcatenatedString)):
|
|
314
|
+
return self._extract_string_value(node)
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
def _extract_string_value(self, node: cst.BaseExpression) -> str:
|
|
318
|
+
"""Extract actual string value from string node."""
|
|
319
|
+
if isinstance(node, cst.SimpleString):
|
|
320
|
+
raw = node.value
|
|
321
|
+
# Remove quotes and prefixes
|
|
322
|
+
for prefix in ["r", "b", "f", "u", "fr", "rf", "br", "rb"]:
|
|
323
|
+
if raw.lower().startswith(prefix):
|
|
324
|
+
raw = raw[len(prefix) :]
|
|
325
|
+
break
|
|
326
|
+
if raw.startswith('"""') or raw.startswith("'''"):
|
|
327
|
+
return raw[3:-3]
|
|
328
|
+
elif raw.startswith('"') or raw.startswith("'"):
|
|
329
|
+
return raw[1:-1]
|
|
330
|
+
return str(node)
|
|
331
|
+
|
|
332
|
+
def _analyze_fallback(self, default_value: str) -> dict[str, Any]:
|
|
333
|
+
"""Fallback analysis using pattern matching."""
|
|
334
|
+
result = {
|
|
335
|
+
"is_marker": False,
|
|
336
|
+
"marker_type": None,
|
|
337
|
+
"marker_args": {},
|
|
338
|
+
"is_dependency": False,
|
|
339
|
+
"dependency_function": None,
|
|
340
|
+
"is_literal": False,
|
|
341
|
+
"literal_value": None,
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
# Check for dependency markers (Depends / Security), including
|
|
345
|
+
# qualified callables like Depends(auth.get_current_user).
|
|
346
|
+
dep_match = re.match(r"^(?:Depends|Security)\s*\(\s*([\w.]+)\s*\)", default_value)
|
|
347
|
+
if dep_match:
|
|
348
|
+
result["is_dependency"] = True
|
|
349
|
+
result["dependency_function"] = dep_match.group(1)
|
|
350
|
+
return result
|
|
351
|
+
|
|
352
|
+
# Check for marker patterns
|
|
353
|
+
for marker in FASTAPI_MARKERS:
|
|
354
|
+
pattern = rf"^{marker}\s*\("
|
|
355
|
+
if re.match(pattern, default_value):
|
|
356
|
+
result["is_marker"] = True
|
|
357
|
+
result["marker_type"] = marker
|
|
358
|
+
return result
|
|
359
|
+
|
|
360
|
+
# Check for simple literals
|
|
361
|
+
if default_value in {"None", "True", "False"}:
|
|
362
|
+
result["is_literal"] = True
|
|
363
|
+
result["literal_value"] = {"None": None, "True": True, "False": False}.get(
|
|
364
|
+
default_value
|
|
365
|
+
)
|
|
366
|
+
elif default_value.startswith(('"', "'")) and default_value.endswith(('"', "'")):
|
|
367
|
+
result["is_literal"] = True
|
|
368
|
+
result["literal_value"] = default_value[1:-1]
|
|
369
|
+
elif default_value.isdigit():
|
|
370
|
+
result["is_literal"] = True
|
|
371
|
+
result["literal_value"] = int(default_value)
|
|
372
|
+
|
|
373
|
+
return result
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# =============================================================================
|
|
377
|
+
# Type Annotation Analyzer
|
|
378
|
+
# =============================================================================
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
class TypeAnnotationAnalyzer:
|
|
382
|
+
"""
|
|
383
|
+
Analyzes type annotations to extract semantic information.
|
|
384
|
+
|
|
385
|
+
Handles:
|
|
386
|
+
- Optional[X] -> X with is_optional=True
|
|
387
|
+
- Annotated[X, ...] -> X with markers extracted
|
|
388
|
+
- list[X] -> X with is_list=True
|
|
389
|
+
- Union[X, None] -> X with is_optional=True
|
|
390
|
+
- Type aliases (e.g. SessionDep = Annotated[Session, Depends(get_db)])
|
|
391
|
+
"""
|
|
392
|
+
|
|
393
|
+
def __init__(
|
|
394
|
+
self,
|
|
395
|
+
known_models: set[str] | None = None,
|
|
396
|
+
type_aliases: dict[str, str] | None = None,
|
|
397
|
+
):
|
|
398
|
+
"""
|
|
399
|
+
Initialize with known model names and type alias mappings.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
known_models: Set of known Pydantic model names
|
|
403
|
+
type_aliases: Mapping of alias name to its expanded RHS text,
|
|
404
|
+
e.g. ``{"SessionDep": "Annotated[Session, Depends(get_db)]"}``
|
|
405
|
+
"""
|
|
406
|
+
self._known_models = known_models or set()
|
|
407
|
+
self._type_aliases = type_aliases or {}
|
|
408
|
+
|
|
409
|
+
def add_known_model(self, name: str) -> None:
|
|
410
|
+
"""Register a known model name."""
|
|
411
|
+
self._known_models.add(name)
|
|
412
|
+
|
|
413
|
+
def _expand_aliases(self, annotation: str) -> str:
|
|
414
|
+
"""Expand known type aliases in *annotation* (single-hop)."""
|
|
415
|
+
stripped = annotation.strip()
|
|
416
|
+
if stripped in self._type_aliases:
|
|
417
|
+
return self._type_aliases[stripped]
|
|
418
|
+
for wrapper in ("Optional[", "Union["):
|
|
419
|
+
if stripped.startswith(wrapper) and stripped.endswith("]"):
|
|
420
|
+
inner = stripped[len(wrapper) : -1].strip()
|
|
421
|
+
parts = self._split_type_args(inner)
|
|
422
|
+
changed = False
|
|
423
|
+
new_parts = []
|
|
424
|
+
for p in parts:
|
|
425
|
+
p_stripped = p.strip()
|
|
426
|
+
if p_stripped in self._type_aliases:
|
|
427
|
+
new_parts.append(self._type_aliases[p_stripped])
|
|
428
|
+
changed = True
|
|
429
|
+
else:
|
|
430
|
+
new_parts.append(p)
|
|
431
|
+
if changed:
|
|
432
|
+
return f"{wrapper}{', '.join(new_parts)}]"
|
|
433
|
+
return annotation
|
|
434
|
+
|
|
435
|
+
def analyze(self, annotation: str | None) -> dict[str, Any]:
|
|
436
|
+
"""
|
|
437
|
+
Analyze a type annotation string.
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
Dict with:
|
|
441
|
+
- base_type: str | None - The innermost type
|
|
442
|
+
- is_optional: bool
|
|
443
|
+
- is_list: bool
|
|
444
|
+
- is_model: bool - Whether base_type is a known Pydantic model
|
|
445
|
+
- annotated_markers: list - Markers from Annotated[X, marker1, marker2]
|
|
446
|
+
"""
|
|
447
|
+
if not annotation:
|
|
448
|
+
return {
|
|
449
|
+
"base_type": None,
|
|
450
|
+
"is_optional": False,
|
|
451
|
+
"is_list": False,
|
|
452
|
+
"is_model": False,
|
|
453
|
+
"annotated_markers": [],
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
annotation = self._expand_aliases(annotation.strip())
|
|
457
|
+
|
|
458
|
+
# Track properties as we unwrap
|
|
459
|
+
is_optional = False
|
|
460
|
+
is_list = False
|
|
461
|
+
annotated_markers = []
|
|
462
|
+
|
|
463
|
+
# Unwrap layers
|
|
464
|
+
unwrapped = annotation
|
|
465
|
+
|
|
466
|
+
# Handle Optional[X] or X | None
|
|
467
|
+
if unwrapped.startswith("Optional[") and unwrapped.endswith("]"):
|
|
468
|
+
is_optional = True
|
|
469
|
+
unwrapped = unwrapped[9:-1].strip()
|
|
470
|
+
elif " | None" in unwrapped:
|
|
471
|
+
is_optional = True
|
|
472
|
+
unwrapped = unwrapped.replace(" | None", "").replace("None | ", "").strip()
|
|
473
|
+
elif unwrapped.startswith("Union[") and ", None]" in unwrapped:
|
|
474
|
+
is_optional = True
|
|
475
|
+
# Extract non-None type from Union
|
|
476
|
+
inner = unwrapped[6:-1]
|
|
477
|
+
types = self._split_type_args(inner)
|
|
478
|
+
non_none = [t for t in types if t.strip() != "None"]
|
|
479
|
+
if len(non_none) == 1:
|
|
480
|
+
unwrapped = non_none[0].strip()
|
|
481
|
+
|
|
482
|
+
# Handle Annotated[X, ...]
|
|
483
|
+
if unwrapped.startswith("Annotated[") and unwrapped.endswith("]"):
|
|
484
|
+
inner = unwrapped[10:-1]
|
|
485
|
+
parts = self._split_type_args(inner)
|
|
486
|
+
if parts:
|
|
487
|
+
unwrapped = parts[0].strip()
|
|
488
|
+
annotated_markers = [p.strip() for p in parts[1:]]
|
|
489
|
+
|
|
490
|
+
# Handle list[X], List[X], Sequence[X]
|
|
491
|
+
list_prefixes = ["list[", "List[", "Sequence[", "typing.List["]
|
|
492
|
+
for prefix in list_prefixes:
|
|
493
|
+
if unwrapped.startswith(prefix) and unwrapped.endswith("]"):
|
|
494
|
+
is_list = True
|
|
495
|
+
unwrapped = unwrapped[len(prefix) : -1].strip()
|
|
496
|
+
break
|
|
497
|
+
|
|
498
|
+
# Check if it's a known model
|
|
499
|
+
is_model = unwrapped in self._known_models
|
|
500
|
+
|
|
501
|
+
return {
|
|
502
|
+
"base_type": unwrapped,
|
|
503
|
+
"is_optional": is_optional,
|
|
504
|
+
"is_list": is_list,
|
|
505
|
+
"is_model": is_model,
|
|
506
|
+
"annotated_markers": annotated_markers,
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
def _split_type_args(self, args_str: str) -> list[str]:
|
|
510
|
+
"""Split type arguments respecting nested brackets."""
|
|
511
|
+
result = []
|
|
512
|
+
current = []
|
|
513
|
+
depth = 0
|
|
514
|
+
|
|
515
|
+
for char in args_str:
|
|
516
|
+
if char == "[":
|
|
517
|
+
depth += 1
|
|
518
|
+
current.append(char)
|
|
519
|
+
elif char == "]":
|
|
520
|
+
depth -= 1
|
|
521
|
+
current.append(char)
|
|
522
|
+
elif char == "," and depth == 0:
|
|
523
|
+
result.append("".join(current))
|
|
524
|
+
current = []
|
|
525
|
+
else:
|
|
526
|
+
current.append(char)
|
|
527
|
+
|
|
528
|
+
if current:
|
|
529
|
+
result.append("".join(current))
|
|
530
|
+
|
|
531
|
+
return [r.strip() for r in result if r.strip()]
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
# =============================================================================
|
|
535
|
+
# Parameter Analyzer
|
|
536
|
+
# =============================================================================
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
class ParameterAnalyzer:
|
|
540
|
+
"""
|
|
541
|
+
Comprehensive parameter analyzer for HTTP handler functions.
|
|
542
|
+
|
|
543
|
+
Combines:
|
|
544
|
+
- Path parameter extraction from route
|
|
545
|
+
- Type annotation analysis
|
|
546
|
+
- Default value analysis
|
|
547
|
+
- Known model detection
|
|
548
|
+
- Import alias resolution
|
|
549
|
+
|
|
550
|
+
This is the main entry point for parameter analysis.
|
|
551
|
+
"""
|
|
552
|
+
|
|
553
|
+
def __init__(
|
|
554
|
+
self,
|
|
555
|
+
import_aliases: dict[str, str] | None = None,
|
|
556
|
+
known_models: set[str] | None = None,
|
|
557
|
+
type_aliases: dict[str, str] | None = None,
|
|
558
|
+
):
|
|
559
|
+
"""
|
|
560
|
+
Initialize the analyzer.
|
|
561
|
+
|
|
562
|
+
Args:
|
|
563
|
+
import_aliases: Mapping of local import names to original names
|
|
564
|
+
known_models: Set of known Pydantic model names
|
|
565
|
+
type_aliases: Mapping of type alias names to their expanded RHS text
|
|
566
|
+
"""
|
|
567
|
+
self._import_aliases = import_aliases or {}
|
|
568
|
+
self._known_models = known_models or set()
|
|
569
|
+
self._type_aliases = type_aliases or {}
|
|
570
|
+
|
|
571
|
+
self._default_analyzer = DefaultValueAnalyzer(self._import_aliases)
|
|
572
|
+
self._type_analyzer = TypeAnnotationAnalyzer(self._known_models, self._type_aliases)
|
|
573
|
+
|
|
574
|
+
def add_import_alias(self, local_name: str, original_name: str) -> None:
|
|
575
|
+
"""Add an import alias mapping."""
|
|
576
|
+
self._import_aliases[local_name] = original_name
|
|
577
|
+
self._default_analyzer = DefaultValueAnalyzer(self._import_aliases)
|
|
578
|
+
|
|
579
|
+
def add_known_model(self, name: str) -> None:
|
|
580
|
+
"""Register a known Pydantic model."""
|
|
581
|
+
self._known_models.add(name)
|
|
582
|
+
self._type_analyzer.add_known_model(name)
|
|
583
|
+
|
|
584
|
+
def analyze_function_params(
|
|
585
|
+
self,
|
|
586
|
+
params: list[ParsedParameter],
|
|
587
|
+
route_path: str | None = None,
|
|
588
|
+
) -> list[AnalyzedParameter]:
|
|
589
|
+
"""
|
|
590
|
+
Analyze all parameters of a function.
|
|
591
|
+
|
|
592
|
+
Args:
|
|
593
|
+
params: List of ParsedParameter from the function
|
|
594
|
+
route_path: Optional route path for path parameter detection
|
|
595
|
+
|
|
596
|
+
Returns:
|
|
597
|
+
List of AnalyzedParameter with full semantic analysis
|
|
598
|
+
"""
|
|
599
|
+
# Extract path parameters from route
|
|
600
|
+
path_params: set[str] = set()
|
|
601
|
+
if route_path:
|
|
602
|
+
path_analysis = extract_path_params(route_path)
|
|
603
|
+
path_params = set(path_analysis.path_params)
|
|
604
|
+
|
|
605
|
+
results = []
|
|
606
|
+
|
|
607
|
+
for param in params:
|
|
608
|
+
# Skip special parameters
|
|
609
|
+
if param.name in SKIP_PARAMS:
|
|
610
|
+
continue
|
|
611
|
+
|
|
612
|
+
analyzed = self._analyze_single_param(param, path_params)
|
|
613
|
+
results.append(analyzed)
|
|
614
|
+
|
|
615
|
+
return results
|
|
616
|
+
|
|
617
|
+
def _analyze_single_param(
|
|
618
|
+
self,
|
|
619
|
+
param: ParsedParameter,
|
|
620
|
+
path_params: set[str],
|
|
621
|
+
) -> AnalyzedParameter:
|
|
622
|
+
"""Analyze a single parameter."""
|
|
623
|
+
# Start with analysis results
|
|
624
|
+
result = AnalyzedParameter(
|
|
625
|
+
name=param.name,
|
|
626
|
+
location="unknown",
|
|
627
|
+
location_confidence=0.0,
|
|
628
|
+
type_annotation=param.type_annotation,
|
|
629
|
+
has_default=param.default_value is not None,
|
|
630
|
+
default_value=param.default_value,
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
# Analyze type annotation
|
|
634
|
+
type_info = self._type_analyzer.analyze(param.type_annotation)
|
|
635
|
+
result.base_type = type_info["base_type"]
|
|
636
|
+
result.is_optional = type_info["is_optional"]
|
|
637
|
+
result.is_list = type_info["is_list"]
|
|
638
|
+
|
|
639
|
+
# Check for Annotated markers (e.g. Annotated[Session, Depends(get_db)])
|
|
640
|
+
annotated_markers = type_info.get("annotated_markers", [])
|
|
641
|
+
for marker in annotated_markers:
|
|
642
|
+
marker_info = self._default_analyzer.analyze(marker)
|
|
643
|
+
if marker_info.get("is_dependency"):
|
|
644
|
+
result.default_is_dependency = True
|
|
645
|
+
result.dependency_function = marker_info.get("dependency_function")
|
|
646
|
+
result.location = "dependency"
|
|
647
|
+
result.location_confidence = 1.0
|
|
648
|
+
return result
|
|
649
|
+
if marker_info.get("is_marker"):
|
|
650
|
+
result.default_is_marker = True
|
|
651
|
+
result.marker_type = marker_info.get("marker_type")
|
|
652
|
+
result.marker_args = marker_info.get("marker_args", {})
|
|
653
|
+
break
|
|
654
|
+
|
|
655
|
+
# Analyze default value
|
|
656
|
+
default_info = self._default_analyzer.analyze(param.default_value)
|
|
657
|
+
|
|
658
|
+
if default_info["is_dependency"]:
|
|
659
|
+
result.default_is_dependency = True
|
|
660
|
+
result.dependency_function = default_info.get("dependency_function")
|
|
661
|
+
result.location = "dependency"
|
|
662
|
+
result.location_confidence = 1.0
|
|
663
|
+
return result
|
|
664
|
+
|
|
665
|
+
if default_info["is_marker"]:
|
|
666
|
+
result.default_is_marker = True
|
|
667
|
+
result.marker_type = default_info.get("marker_type")
|
|
668
|
+
result.marker_args = default_info.get("marker_args", {})
|
|
669
|
+
|
|
670
|
+
# Extract constraints from marker args
|
|
671
|
+
for key in ["min_length", "max_length", "gt", "ge", "lt", "le", "regex", "pattern"]:
|
|
672
|
+
if key in result.marker_args:
|
|
673
|
+
result.constraints[key] = result.marker_args[key]
|
|
674
|
+
|
|
675
|
+
# Determine location with precedence rules
|
|
676
|
+
location, confidence = self._determine_location(
|
|
677
|
+
param_name=param.name,
|
|
678
|
+
path_params=path_params,
|
|
679
|
+
marker_type=result.marker_type,
|
|
680
|
+
base_type=result.base_type,
|
|
681
|
+
is_model=type_info["is_model"],
|
|
682
|
+
has_default=result.has_default,
|
|
683
|
+
default_value=param.default_value,
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
result.location = location
|
|
687
|
+
result.location_confidence = confidence
|
|
688
|
+
|
|
689
|
+
return result
|
|
690
|
+
|
|
691
|
+
def _determine_location(
|
|
692
|
+
self,
|
|
693
|
+
param_name: str,
|
|
694
|
+
path_params: set[str],
|
|
695
|
+
marker_type: str | None,
|
|
696
|
+
base_type: str | None,
|
|
697
|
+
is_model: bool,
|
|
698
|
+
has_default: bool,
|
|
699
|
+
default_value: str | None,
|
|
700
|
+
) -> tuple[str, float]:
|
|
701
|
+
"""
|
|
702
|
+
Determine parameter location with confidence score.
|
|
703
|
+
|
|
704
|
+
Precedence (highest to lowest):
|
|
705
|
+
1. Explicit marker (Query, Path, Header, etc.) - 1.0
|
|
706
|
+
2. Path parameter match from route - 1.0
|
|
707
|
+
3. Pydantic model type - 0.95
|
|
708
|
+
4. Type-based inference - 0.7
|
|
709
|
+
5. Name-based inference - 0.5
|
|
710
|
+
6. Default: query - 0.3
|
|
711
|
+
|
|
712
|
+
Returns:
|
|
713
|
+
Tuple of (location, confidence)
|
|
714
|
+
"""
|
|
715
|
+
# 1. Explicit marker takes highest precedence
|
|
716
|
+
if marker_type:
|
|
717
|
+
location = FASTAPI_MARKERS.get(marker_type, "query")
|
|
718
|
+
return (location, 1.0)
|
|
719
|
+
|
|
720
|
+
# 2. Path parameter from route
|
|
721
|
+
if param_name in path_params:
|
|
722
|
+
return ("path", 1.0)
|
|
723
|
+
|
|
724
|
+
# 3. Pydantic model = body
|
|
725
|
+
if is_model:
|
|
726
|
+
return ("body", 0.95)
|
|
727
|
+
|
|
728
|
+
# Check base type for special cases
|
|
729
|
+
if base_type:
|
|
730
|
+
base_lower = base_type.lower()
|
|
731
|
+
|
|
732
|
+
# 4. Type-based inference
|
|
733
|
+
if base_type in {"UploadFile", "bytes"} or "uploadfile" in base_lower:
|
|
734
|
+
return ("body", 0.9)
|
|
735
|
+
|
|
736
|
+
# Common body type names
|
|
737
|
+
if any(
|
|
738
|
+
pattern in base_lower
|
|
739
|
+
for pattern in ["request", "payload", "input", "create", "update"]
|
|
740
|
+
):
|
|
741
|
+
if base_type[0].isupper(): # Looks like a class name
|
|
742
|
+
return ("body", 0.8)
|
|
743
|
+
|
|
744
|
+
# 5. Name-based inference (lower confidence)
|
|
745
|
+
name_lower = param_name.lower()
|
|
746
|
+
|
|
747
|
+
# Header patterns
|
|
748
|
+
if any(
|
|
749
|
+
pattern in name_lower for pattern in ["header", "x_", "authorization", "content_type"]
|
|
750
|
+
):
|
|
751
|
+
return ("header", 0.6)
|
|
752
|
+
|
|
753
|
+
# Cookie patterns
|
|
754
|
+
if any(pattern in name_lower for pattern in ["cookie", "session_id", "csrf"]):
|
|
755
|
+
return ("cookie", 0.6)
|
|
756
|
+
|
|
757
|
+
# 6. Default: query parameters
|
|
758
|
+
# But with even lower confidence if no type annotation
|
|
759
|
+
if base_type:
|
|
760
|
+
return ("query", 0.5)
|
|
761
|
+
else:
|
|
762
|
+
return ("query", 0.3)
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
# =============================================================================
|
|
766
|
+
# Convenience Functions
|
|
767
|
+
# =============================================================================
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def analyze_route_parameters(
|
|
771
|
+
params: list[ParsedParameter],
|
|
772
|
+
route_path: str,
|
|
773
|
+
import_aliases: dict[str, str] | None = None,
|
|
774
|
+
known_models: set[str] | None = None,
|
|
775
|
+
) -> list[AnalyzedParameter]:
|
|
776
|
+
"""
|
|
777
|
+
Convenience function to analyze route handler parameters.
|
|
778
|
+
|
|
779
|
+
Args:
|
|
780
|
+
params: Function parameters
|
|
781
|
+
route_path: The route path (e.g., "/users/{user_id}")
|
|
782
|
+
import_aliases: Import alias mappings
|
|
783
|
+
known_models: Known Pydantic model names
|
|
784
|
+
|
|
785
|
+
Returns:
|
|
786
|
+
List of analyzed parameters
|
|
787
|
+
"""
|
|
788
|
+
analyzer = ParameterAnalyzer(import_aliases, known_models)
|
|
789
|
+
return analyzer.analyze_function_params(params, route_path)
|