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,2546 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ASP.NET Core framework plugin.
|
|
3
|
+
|
|
4
|
+
Extracts HTTP routes, auth schemes, dependencies, JWT config, and
|
|
5
|
+
middleware from ASP.NET Core applications using attribute-based detection.
|
|
6
|
+
|
|
7
|
+
Supports
|
|
8
|
+
--------
|
|
9
|
+
- [ApiController] / [Controller] with [Route("prefix")]
|
|
10
|
+
- [HttpGet/Post/Put/Delete/Patch/Head/Options] route attributes
|
|
11
|
+
- [Route] on methods as an additional path contributor
|
|
12
|
+
- Multi-value path templates: "/api/{version:apiVersion}/users/{id:int}"
|
|
13
|
+
- [FromRoute], [FromQuery], [FromBody], [FromHeader], [FromForm] parameters
|
|
14
|
+
- Bean Validation equivalents: [Required], [Range], [MinLength], [MaxLength],
|
|
15
|
+
[StringLength], [RegularExpression], [EmailAddress]
|
|
16
|
+
- [Authorize] / [AllowAnonymous] per-controller and per-action
|
|
17
|
+
- [Authorize(Roles = "Admin,User")] role extraction
|
|
18
|
+
- [Authorize(Policy = "...")] policy extraction
|
|
19
|
+
- services.AddAuthentication() / AddJwtBearer() / AddOAuth() in Startup/Program
|
|
20
|
+
- Middleware: IMiddleware, IActionFilter, app.UseMiddleware<T>(), app.Use()
|
|
21
|
+
- Minimal API (MapGet / MapPost …) on WebApplication
|
|
22
|
+
- [ProducesResponseType] / [Produces] / [Consumes] content negotiation
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import contextlib
|
|
28
|
+
import logging
|
|
29
|
+
import re
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
32
|
+
|
|
33
|
+
from ...core.types import (
|
|
34
|
+
AuthDependencyType,
|
|
35
|
+
AuthSchemeType,
|
|
36
|
+
CodeLocation,
|
|
37
|
+
Confidence,
|
|
38
|
+
Framework,
|
|
39
|
+
HttpMethod,
|
|
40
|
+
Language,
|
|
41
|
+
ParameterLocation,
|
|
42
|
+
QualifiedName,
|
|
43
|
+
)
|
|
44
|
+
from ...parsing.base import (
|
|
45
|
+
ParsedCallSite,
|
|
46
|
+
ParsedClass,
|
|
47
|
+
ParsedDecorator,
|
|
48
|
+
ParsedFile,
|
|
49
|
+
ParsedFunction,
|
|
50
|
+
)
|
|
51
|
+
from ...parsing.services import AnalysisContext
|
|
52
|
+
from ..base import (
|
|
53
|
+
BaseFrameworkPlugin,
|
|
54
|
+
ExtractedAuthDependency,
|
|
55
|
+
ExtractedAuthScheme,
|
|
56
|
+
ExtractedBody,
|
|
57
|
+
ExtractedDependency,
|
|
58
|
+
ExtractedJwtConfig,
|
|
59
|
+
ExtractedMiddleware,
|
|
60
|
+
ExtractedParameter,
|
|
61
|
+
ExtractedResponse,
|
|
62
|
+
ExtractedRoute,
|
|
63
|
+
FrameworkPluginRegistry,
|
|
64
|
+
)
|
|
65
|
+
from .jwt_config_extractor import DotNetJwtConfigExtractor
|
|
66
|
+
|
|
67
|
+
if TYPE_CHECKING:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
logger = logging.getLogger(__name__)
|
|
71
|
+
|
|
72
|
+
# =============================================================================
|
|
73
|
+
# Constants
|
|
74
|
+
# =============================================================================
|
|
75
|
+
|
|
76
|
+
# Attribute name → HTTP method
|
|
77
|
+
_HTTP_METHOD_ATTRS: dict[str, HttpMethod] = {
|
|
78
|
+
"HttpGet": HttpMethod.GET,
|
|
79
|
+
"HttpPost": HttpMethod.POST,
|
|
80
|
+
"HttpPut": HttpMethod.PUT,
|
|
81
|
+
"HttpDelete": HttpMethod.DELETE,
|
|
82
|
+
"HttpPatch": HttpMethod.PATCH,
|
|
83
|
+
"HttpHead": HttpMethod.HEAD,
|
|
84
|
+
"HttpOptions": HttpMethod.OPTIONS,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# Minimal API WebApplication extension methods → HTTP method
|
|
88
|
+
_MINIMAL_API_METHODS: dict[str, HttpMethod] = {
|
|
89
|
+
"MapGet": HttpMethod.GET,
|
|
90
|
+
"MapPost": HttpMethod.POST,
|
|
91
|
+
"MapPut": HttpMethod.PUT,
|
|
92
|
+
"MapDelete": HttpMethod.DELETE,
|
|
93
|
+
"MapPatch": HttpMethod.PATCH,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# IEndpointConventionBuilder extension methods that can be chained on Map* calls.
|
|
97
|
+
# Used to recognise that a call site is chained on a route registration.
|
|
98
|
+
_MINIMAL_API_CHAINERS: frozenset[str] = frozenset(
|
|
99
|
+
{
|
|
100
|
+
"RequireAuthorization",
|
|
101
|
+
"AllowAnonymous",
|
|
102
|
+
"RequireRateLimiting",
|
|
103
|
+
"RequireCors",
|
|
104
|
+
"RequirePermission",
|
|
105
|
+
"RequireScope",
|
|
106
|
+
"WithName",
|
|
107
|
+
"WithTags",
|
|
108
|
+
"WithGroupName",
|
|
109
|
+
"WithOpenApi",
|
|
110
|
+
"WithMetadata",
|
|
111
|
+
"WithApiVersionSet",
|
|
112
|
+
"Produces",
|
|
113
|
+
"ProducesProblem",
|
|
114
|
+
"WithSummary",
|
|
115
|
+
"WithDescription",
|
|
116
|
+
"ExcludeFromDescription",
|
|
117
|
+
"DisableRateLimiting",
|
|
118
|
+
"WithIdempotency",
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Subset that carry explicit auth intent and should generate a dep_ref.
|
|
123
|
+
_MINIMAL_API_AUTH_CHAINERS: frozenset[str] = frozenset(
|
|
124
|
+
{
|
|
125
|
+
"RequireAuthorization",
|
|
126
|
+
"AllowAnonymous",
|
|
127
|
+
"RequirePermission",
|
|
128
|
+
"RequireScope",
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
_CONTROLLER_ATTRS: frozenset[str] = frozenset({"ApiController", "Controller"})
|
|
133
|
+
|
|
134
|
+
# IEndpointGroup base interface name used in the CleanArchitecture / group-builder pattern.
|
|
135
|
+
_ENDPOINT_GROUP_BASES: frozenset[str] = frozenset({"IEndpointGroup"})
|
|
136
|
+
|
|
137
|
+
# Standard ASP.NET Core Identity API endpoints registered by MapIdentityApi<T>().
|
|
138
|
+
# Tuple: (HttpMethod, path-suffix, requires_auth)
|
|
139
|
+
_IDENTITY_API_ROUTES: list[tuple[HttpMethod, str, bool]] = [
|
|
140
|
+
(HttpMethod.POST, "register", False),
|
|
141
|
+
(HttpMethod.POST, "login", False),
|
|
142
|
+
(HttpMethod.POST, "refresh", False),
|
|
143
|
+
(HttpMethod.GET, "confirmEmail", False),
|
|
144
|
+
(HttpMethod.POST, "resendConfirmationEmail", False),
|
|
145
|
+
(HttpMethod.POST, "forgotPassword", False),
|
|
146
|
+
(HttpMethod.POST, "resetPassword", False),
|
|
147
|
+
(HttpMethod.POST, "manage/2fa", True),
|
|
148
|
+
(HttpMethod.GET, "manage/info", True),
|
|
149
|
+
(HttpMethod.POST, "manage/info", True),
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
# Base classes that mark a class as a controller/endpoint (not a DTO)
|
|
153
|
+
_CONTROLLER_BASE_CLASSES: frozenset[str] = frozenset(
|
|
154
|
+
{
|
|
155
|
+
"ControllerBase",
|
|
156
|
+
"Controller",
|
|
157
|
+
"ApiController",
|
|
158
|
+
"EndpointBaseAsync",
|
|
159
|
+
"EndpointBase", # Ardalis ApiEndpoints
|
|
160
|
+
"PageModel", # Razor Pages
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
_SECURITY_ATTRS: frozenset[str] = frozenset(
|
|
165
|
+
{
|
|
166
|
+
"Authorize",
|
|
167
|
+
"AllowAnonymous",
|
|
168
|
+
"RequireScope",
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Method names inherited from System.Object — never conventional MVC actions.
|
|
173
|
+
_OBJECT_METHOD_NAMES: frozenset[str] = frozenset(
|
|
174
|
+
{
|
|
175
|
+
"Equals",
|
|
176
|
+
"GetHashCode",
|
|
177
|
+
"ToString",
|
|
178
|
+
"Finalize",
|
|
179
|
+
}
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Attributes that mark a controller method as NOT routable to HTTP.
|
|
183
|
+
# - `[NonAction]` — explicit "don't route" marker (both MVC + Web API)
|
|
184
|
+
# - `[ChildActionOnly]` — System.Web.Mvc; method renders a partial view via
|
|
185
|
+
# Html.Action(...) and is invoked from a parent view,
|
|
186
|
+
# never from a direct HTTP request.
|
|
187
|
+
_NON_ROUTABLE_ATTRS: frozenset[str] = frozenset(
|
|
188
|
+
{
|
|
189
|
+
"NonAction",
|
|
190
|
+
"ChildActionOnly",
|
|
191
|
+
}
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# camelCase / PascalCase → kebab-case for the slugify route-parameter transformer.
|
|
195
|
+
# Matches the canonical Microsoft example: Regex.Replace(value, "([a-z])([A-Z])", "$1-$2").ToLower().
|
|
196
|
+
_SLUGIFY_RE = re.compile(r"([a-z0-9])([A-Z])")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _slugify(name: str) -> str:
|
|
200
|
+
return _SLUGIFY_RE.sub(r"\1-\2", name).lower()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _file_registers_slugify(parsed_file: ParsedFile) -> bool:
|
|
204
|
+
"""
|
|
205
|
+
Return True if `parsed_file` contains a `MapControllerRoute` call whose
|
|
206
|
+
route template uses the `:slugify=` constraint — the canonical signal
|
|
207
|
+
that the project has registered `SlugifyParameterTransformer`.
|
|
208
|
+
"""
|
|
209
|
+
for call in parsed_file.call_sites:
|
|
210
|
+
if not call.callee_name.endswith("MapControllerRoute"):
|
|
211
|
+
continue
|
|
212
|
+
for arg in call.arguments:
|
|
213
|
+
value = arg.literal_value
|
|
214
|
+
if value and isinstance(value, str) and ":slugify=" in value:
|
|
215
|
+
return True
|
|
216
|
+
return False
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# Match `controller = "Foo"` and `action = "Bar"` inside `new { ... }` anonymous
|
|
220
|
+
# initializers passed to routes.MapRoute / MapLocalizedRoute defaults.
|
|
221
|
+
_MAPROUTE_CONTROLLER_RE = re.compile(
|
|
222
|
+
r'\bcontroller\s*=\s*"([A-Za-z_][\w]*)"',
|
|
223
|
+
re.IGNORECASE,
|
|
224
|
+
)
|
|
225
|
+
_MAPROUTE_ACTION_RE = re.compile(
|
|
226
|
+
r'\baction\s*=\s*"([A-Za-z_][\w]*)"',
|
|
227
|
+
re.IGNORECASE,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _scan_maproute_registrations(
|
|
232
|
+
parsed_file: ParsedFile,
|
|
233
|
+
) -> dict[tuple[str, str], str]:
|
|
234
|
+
"""
|
|
235
|
+
Find every `routes.MapRoute(name, url, defaults, ...)` or
|
|
236
|
+
`routes.MapLocalizedRoute(name, url, defaults, ...)` call and return
|
|
237
|
+
`{(controller, action): url_pattern}` extracted from the literal URL
|
|
238
|
+
+ the `new { controller = "X", action = "Y" }` defaults expression.
|
|
239
|
+
|
|
240
|
+
Routes whose defaults can't be parsed are skipped silently.
|
|
241
|
+
"""
|
|
242
|
+
overrides: dict[tuple[str, str], str] = {}
|
|
243
|
+
for call in parsed_file.call_sites:
|
|
244
|
+
leaf = call.callee_name.rsplit(".", 1)[-1]
|
|
245
|
+
if leaf not in ("MapRoute", "MapLocalizedRoute"):
|
|
246
|
+
continue
|
|
247
|
+
if len(call.arguments) < 3:
|
|
248
|
+
continue
|
|
249
|
+
url_arg = call.arguments[1]
|
|
250
|
+
defaults_arg = call.arguments[2]
|
|
251
|
+
if not isinstance(url_arg.literal_value, str):
|
|
252
|
+
continue
|
|
253
|
+
defaults_expr = defaults_arg.expression_text or ""
|
|
254
|
+
if "new" not in defaults_expr:
|
|
255
|
+
continue
|
|
256
|
+
ctl_match = _MAPROUTE_CONTROLLER_RE.search(defaults_expr)
|
|
257
|
+
act_match = _MAPROUTE_ACTION_RE.search(defaults_expr)
|
|
258
|
+
if not ctl_match or not act_match:
|
|
259
|
+
continue
|
|
260
|
+
url = url_arg.literal_value
|
|
261
|
+
if not url.startswith("/"):
|
|
262
|
+
url = "/" + url
|
|
263
|
+
# Trailing slash kept as-is; downstream normalization decides.
|
|
264
|
+
overrides[(ctl_match.group(1), act_match.group(1))] = url
|
|
265
|
+
return overrides
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# Auth-scheme detection from .AddXxx() call sites in Program.cs / Startup.cs.
|
|
269
|
+
# Match order matters: more-specific patterns must come before less-specific.
|
|
270
|
+
# Pattern is a lowercase substring of the callee name.
|
|
271
|
+
_AUTH_SCHEME_CALL_PATTERNS: tuple[tuple[str, AuthSchemeType, str, Confidence], ...] = (
|
|
272
|
+
("addjwtbearer", AuthSchemeType.JWT_BEARER, "jwt_bearer", Confidence.HIGH),
|
|
273
|
+
("addoauth2", AuthSchemeType.OAUTH2_AUTHORIZATION_CODE, "oauth2", Confidence.MEDIUM),
|
|
274
|
+
("addoauth", AuthSchemeType.OAUTH2_AUTHORIZATION_CODE, "oauth2", Confidence.MEDIUM),
|
|
275
|
+
("addopenidconnect", AuthSchemeType.OAUTH2_AUTHORIZATION_CODE, "oidc", Confidence.HIGH),
|
|
276
|
+
# AspNet Core Identity (AddIdentity / AddDefaultIdentity) is cookie-driven.
|
|
277
|
+
# Match these *before* the generic AddCookie pattern below so the name
|
|
278
|
+
# surfaces as "identity" rather than "cookie".
|
|
279
|
+
("adddefaultidentity", AuthSchemeType.SESSION_COOKIE, "identity", Confidence.HIGH),
|
|
280
|
+
("addidentitycore", AuthSchemeType.SESSION_COOKIE, "identity", Confidence.HIGH),
|
|
281
|
+
("addidentity", AuthSchemeType.SESSION_COOKIE, "identity", Confidence.HIGH),
|
|
282
|
+
("addcookie", AuthSchemeType.SESSION_COOKIE, "cookie", Confidence.HIGH),
|
|
283
|
+
("addapikeysupport", AuthSchemeType.API_KEY_HEADER, "api_key", Confidence.MEDIUM),
|
|
284
|
+
("addapikey", AuthSchemeType.API_KEY_HEADER, "api_key", Confidence.MEDIUM),
|
|
285
|
+
("addnegotiate", AuthSchemeType.CUSTOM, "negotiate", Confidence.HIGH),
|
|
286
|
+
("addcertificate", AuthSchemeType.CUSTOM, "client_cert", Confidence.HIGH),
|
|
287
|
+
("addbasic", AuthSchemeType.HTTP_BASIC, "basic", Confidence.MEDIUM),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# Return types that mark a method as an MVC action when no [HttpVerb] attribute
|
|
291
|
+
# is present. The C# parser doesn't expose method visibility, so this filter
|
|
292
|
+
# substitutes for "is public": real actions return one of these; private
|
|
293
|
+
# helpers on the same controller usually return view-model DTOs.
|
|
294
|
+
_ACTION_RETURN_TYPE_NAMES: frozenset[str] = frozenset(
|
|
295
|
+
{
|
|
296
|
+
"IActionResult",
|
|
297
|
+
"ActionResult",
|
|
298
|
+
"IResult",
|
|
299
|
+
"ViewResult",
|
|
300
|
+
"JsonResult",
|
|
301
|
+
"ContentResult",
|
|
302
|
+
"FileResult",
|
|
303
|
+
"RedirectResult",
|
|
304
|
+
"RedirectToActionResult",
|
|
305
|
+
"RedirectToRouteResult",
|
|
306
|
+
"RedirectToPageResult",
|
|
307
|
+
"LocalRedirectResult",
|
|
308
|
+
"OkResult",
|
|
309
|
+
"OkObjectResult",
|
|
310
|
+
"NotFoundResult",
|
|
311
|
+
"NotFoundObjectResult",
|
|
312
|
+
"BadRequestResult",
|
|
313
|
+
"BadRequestObjectResult",
|
|
314
|
+
"UnauthorizedResult",
|
|
315
|
+
"StatusCodeResult",
|
|
316
|
+
"ObjectResult",
|
|
317
|
+
"EmptyResult",
|
|
318
|
+
"NoContentResult",
|
|
319
|
+
"PartialViewResult",
|
|
320
|
+
"ChallengeResult",
|
|
321
|
+
"ForbidResult",
|
|
322
|
+
"SignOutResult",
|
|
323
|
+
"CreatedResult",
|
|
324
|
+
"CreatedAtActionResult",
|
|
325
|
+
"CreatedAtRouteResult",
|
|
326
|
+
"AcceptedResult",
|
|
327
|
+
"AcceptedAtActionResult",
|
|
328
|
+
"AcceptedAtRouteResult",
|
|
329
|
+
"ConflictResult",
|
|
330
|
+
"ConflictObjectResult",
|
|
331
|
+
"UnprocessableEntityResult",
|
|
332
|
+
"UnprocessableEntityObjectResult",
|
|
333
|
+
# Web API 2 (System.Web.Http.ApiController) result types
|
|
334
|
+
"IHttpActionResult",
|
|
335
|
+
"HttpResponseMessage",
|
|
336
|
+
}
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Bases that mark a class as a Web API 2 ApiController (System.Web.Http).
|
|
340
|
+
_WEB_API_CONTROLLER_BASES: frozenset[str] = frozenset({"ApiController"})
|
|
341
|
+
|
|
342
|
+
# C# primitive / built-in scalar type names used as route-bindable parameters.
|
|
343
|
+
# A method parameter of one of these types in a Web API 2 conventional route
|
|
344
|
+
# becomes the `{id}` segment; complex types are bound from the body instead.
|
|
345
|
+
_PRIMITIVE_PARAM_TYPES: frozenset[str] = frozenset(
|
|
346
|
+
{
|
|
347
|
+
"bool",
|
|
348
|
+
"Boolean",
|
|
349
|
+
"byte",
|
|
350
|
+
"Byte",
|
|
351
|
+
"sbyte",
|
|
352
|
+
"SByte",
|
|
353
|
+
"char",
|
|
354
|
+
"Char",
|
|
355
|
+
"short",
|
|
356
|
+
"Int16",
|
|
357
|
+
"ushort",
|
|
358
|
+
"UInt16",
|
|
359
|
+
"int",
|
|
360
|
+
"Int32",
|
|
361
|
+
"uint",
|
|
362
|
+
"UInt32",
|
|
363
|
+
"long",
|
|
364
|
+
"Int64",
|
|
365
|
+
"ulong",
|
|
366
|
+
"UInt64",
|
|
367
|
+
"float",
|
|
368
|
+
"Single",
|
|
369
|
+
"double",
|
|
370
|
+
"Double",
|
|
371
|
+
"decimal",
|
|
372
|
+
"Decimal",
|
|
373
|
+
"string",
|
|
374
|
+
"String",
|
|
375
|
+
"Guid",
|
|
376
|
+
"DateTime",
|
|
377
|
+
"DateTimeOffset",
|
|
378
|
+
"DateOnly",
|
|
379
|
+
"TimeOnly",
|
|
380
|
+
}
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
# Web API 2 method-name-prefix → HTTP verb convention. Action methods on
|
|
384
|
+
# an ApiController whose name starts with one of these prefixes (and that
|
|
385
|
+
# carry no explicit [HttpVerb] attribute) bind to the corresponding verb.
|
|
386
|
+
# Order matters here: longer prefixes must come first so that e.g.
|
|
387
|
+
# `Options` doesn't accidentally match an OptionsForX method as just `O`.
|
|
388
|
+
_WEBAPI_VERB_PREFIXES: tuple[tuple[str, HttpMethod], ...] = (
|
|
389
|
+
("Options", HttpMethod.OPTIONS),
|
|
390
|
+
("Delete", HttpMethod.DELETE),
|
|
391
|
+
("Patch", HttpMethod.PATCH),
|
|
392
|
+
("Post", HttpMethod.POST),
|
|
393
|
+
("Head", HttpMethod.HEAD),
|
|
394
|
+
("Put", HttpMethod.PUT),
|
|
395
|
+
("Get", HttpMethod.GET),
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
_ASPNET_IMPORTS: frozenset[str] = frozenset(
|
|
399
|
+
{
|
|
400
|
+
"Microsoft.AspNetCore.Mvc",
|
|
401
|
+
"Microsoft.AspNetCore.Http",
|
|
402
|
+
"Microsoft.AspNetCore.Authorization",
|
|
403
|
+
"Microsoft.AspNetCore.Routing",
|
|
404
|
+
"Microsoft.AspNetCore.Builder",
|
|
405
|
+
"Microsoft.Extensions.DependencyInjection",
|
|
406
|
+
}
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
# Parameter binding attributes → ParameterLocation
|
|
410
|
+
_PARAM_LOCATION: dict[str, ParameterLocation] = {
|
|
411
|
+
"FromRoute": ParameterLocation.PATH,
|
|
412
|
+
"FromQuery": ParameterLocation.QUERY,
|
|
413
|
+
"FromHeader": ParameterLocation.HEADER,
|
|
414
|
+
"FromForm": ParameterLocation.QUERY, # surface as query; body flagged separately
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
# Bean-validation-equivalent constraints we surface
|
|
418
|
+
_CONSTRAINT_ATTRS: frozenset[str] = frozenset(
|
|
419
|
+
{
|
|
420
|
+
"Required",
|
|
421
|
+
"Range",
|
|
422
|
+
"MinLength",
|
|
423
|
+
"MaxLength",
|
|
424
|
+
"StringLength",
|
|
425
|
+
"RegularExpression",
|
|
426
|
+
"EmailAddress",
|
|
427
|
+
"Url",
|
|
428
|
+
"Phone",
|
|
429
|
+
"CreditCard",
|
|
430
|
+
}
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
_AUTH_KEYWORDS: frozenset[str] = frozenset(
|
|
434
|
+
{
|
|
435
|
+
"auth",
|
|
436
|
+
"authentication",
|
|
437
|
+
"authorization",
|
|
438
|
+
"security",
|
|
439
|
+
"jwt",
|
|
440
|
+
"token",
|
|
441
|
+
"user",
|
|
442
|
+
"identity",
|
|
443
|
+
"principal",
|
|
444
|
+
"credential",
|
|
445
|
+
"login",
|
|
446
|
+
"filter",
|
|
447
|
+
"middleware",
|
|
448
|
+
}
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
# HTTP status code map (covers common ASP.NET / RFC names)
|
|
452
|
+
_STATUS_NAMES: dict[str, int] = {
|
|
453
|
+
"Ok": 200,
|
|
454
|
+
"Created": 201,
|
|
455
|
+
"NoContent": 204,
|
|
456
|
+
"BadRequest": 400,
|
|
457
|
+
"Unauthorized": 401,
|
|
458
|
+
"Forbidden": 403,
|
|
459
|
+
"NotFound": 404,
|
|
460
|
+
"Conflict": 409,
|
|
461
|
+
"UnprocessableEntity": 422,
|
|
462
|
+
"InternalServerError": 500,
|
|
463
|
+
# Numeric strings come through as-is
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
# =============================================================================
|
|
468
|
+
# Helpers
|
|
469
|
+
# =============================================================================
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _str_to_qname(s: str) -> QualifiedName:
|
|
473
|
+
"""Convert a dotted string like 'app.MapGet' to a QualifiedName."""
|
|
474
|
+
if "." in s:
|
|
475
|
+
module, _, name = s.rpartition(".")
|
|
476
|
+
return QualifiedName(module=module, name=name)
|
|
477
|
+
return QualifiedName(module="", name=s)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
# =============================================================================
|
|
481
|
+
# AspNetCorePlugin
|
|
482
|
+
# =============================================================================
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
class AspNetCorePlugin(BaseFrameworkPlugin):
|
|
486
|
+
"""
|
|
487
|
+
Framework plugin for ASP.NET Core applications.
|
|
488
|
+
|
|
489
|
+
Extracts routes, auth, dependencies, and middleware from
|
|
490
|
+
ASP.NET Core attribute-based and minimal-API patterns.
|
|
491
|
+
"""
|
|
492
|
+
|
|
493
|
+
FRAMEWORK: ClassVar[Framework] = Framework.ASPNET_CORE
|
|
494
|
+
LANGUAGE: ClassVar[Language] = Language.CSHARP
|
|
495
|
+
DETECTION_IMPORTS: ClassVar[frozenset[str]] = _ASPNET_IMPORTS
|
|
496
|
+
|
|
497
|
+
def __init__(self) -> None:
|
|
498
|
+
# Project-wide signals derived from cross-file scanning, cached by
|
|
499
|
+
# AnalysisContext identity so the work is done once per analysis run.
|
|
500
|
+
self._slugify_active_cache: dict[int, bool] = {}
|
|
501
|
+
self._maproute_overrides_cache: dict[int, dict[tuple[str, str], str]] = {}
|
|
502
|
+
|
|
503
|
+
# -------------------------------------------------------------------------
|
|
504
|
+
# Detection
|
|
505
|
+
# -------------------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
def detect(self, parsed_file: ParsedFile) -> bool:
|
|
508
|
+
has_system_web = any(
|
|
509
|
+
(imp.module or "").startswith("System.Web.Mvc")
|
|
510
|
+
or (imp.module or "").startswith("System.Web.Http")
|
|
511
|
+
for imp in parsed_file.imports
|
|
512
|
+
)
|
|
513
|
+
if has_system_web:
|
|
514
|
+
return False # legacy file — handled by LegacyAspNetPlugin
|
|
515
|
+
for imp in parsed_file.imports:
|
|
516
|
+
module = imp.module or ""
|
|
517
|
+
if module.startswith("Microsoft.AspNetCore") or module.startswith(
|
|
518
|
+
"Microsoft.Extensions.DependencyInjection"
|
|
519
|
+
):
|
|
520
|
+
return True
|
|
521
|
+
# Also detect via class attributes (in case using statements were missed)
|
|
522
|
+
for cls in parsed_file.classes:
|
|
523
|
+
if any(d.name in _CONTROLLER_ATTRS for d in cls.decorators):
|
|
524
|
+
return True
|
|
525
|
+
# Also detect via base class (global usings / no explicit imports)
|
|
526
|
+
for cls in parsed_file.classes:
|
|
527
|
+
for base in cls.base_classes:
|
|
528
|
+
if base.rsplit(".", 1)[-1] in _CONTROLLER_BASE_CLASSES:
|
|
529
|
+
return True
|
|
530
|
+
return False
|
|
531
|
+
|
|
532
|
+
# -------------------------------------------------------------------------
|
|
533
|
+
# Route extraction
|
|
534
|
+
# -------------------------------------------------------------------------
|
|
535
|
+
|
|
536
|
+
def extract_routes(
|
|
537
|
+
self,
|
|
538
|
+
parsed_file: ParsedFile,
|
|
539
|
+
context: AnalysisContext | None = None,
|
|
540
|
+
) -> list[ExtractedRoute]:
|
|
541
|
+
routes: list[ExtractedRoute] = []
|
|
542
|
+
slugify_active = self._project_uses_slugify(context, parsed_file)
|
|
543
|
+
maproute_overrides = self._project_maproute_overrides(context, parsed_file)
|
|
544
|
+
|
|
545
|
+
for cls in parsed_file.classes:
|
|
546
|
+
if not self._is_controller(cls):
|
|
547
|
+
continue
|
|
548
|
+
# Razor Pages PageModel subclasses are handled by the dedicated loop below.
|
|
549
|
+
if self._is_razor_page(cls):
|
|
550
|
+
continue
|
|
551
|
+
|
|
552
|
+
class_prefix = self._get_class_prefix(cls, parsed_file, slugify=slugify_active)
|
|
553
|
+
|
|
554
|
+
is_conventional = not any(
|
|
555
|
+
d.name == "Route" for d in cls.decorators
|
|
556
|
+
) and self._is_controller(cls)
|
|
557
|
+
|
|
558
|
+
for method in cls.methods:
|
|
559
|
+
routes.extend(
|
|
560
|
+
self._extract_routes_from_method(
|
|
561
|
+
method,
|
|
562
|
+
class_prefix,
|
|
563
|
+
cls,
|
|
564
|
+
parsed_file,
|
|
565
|
+
context,
|
|
566
|
+
is_conventional_mvc=is_conventional,
|
|
567
|
+
slugify=slugify_active,
|
|
568
|
+
maproute_overrides=maproute_overrides,
|
|
569
|
+
)
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
# Razor Pages: PageModel subclasses with OnGet*/OnPost* handler methods
|
|
573
|
+
routes.extend(self._extract_razor_page_routes(parsed_file, context))
|
|
574
|
+
|
|
575
|
+
# Minimal API: top-level call sites (app.MapGet("/path", handler))
|
|
576
|
+
routes.extend(self._extract_minimal_api_routes(parsed_file, context))
|
|
577
|
+
|
|
578
|
+
# IEndpointGroup pattern (CleanArchitecture style): groupBuilder.Map*(handler, pattern)
|
|
579
|
+
routes.extend(self._extract_endpoint_group_routes(parsed_file, context))
|
|
580
|
+
|
|
581
|
+
return routes
|
|
582
|
+
|
|
583
|
+
def _is_controller(self, cls: ParsedClass) -> bool:
|
|
584
|
+
if any(d.name in _CONTROLLER_ATTRS for d in cls.decorators):
|
|
585
|
+
return True
|
|
586
|
+
# Ardalis ApiEndpoints / ControllerBase subclasses without [ApiController]
|
|
587
|
+
for base in cls.base_classes:
|
|
588
|
+
simple = base.rsplit(".", 1)[-1]
|
|
589
|
+
if simple in _CONTROLLER_BASE_CLASSES or base in _CONTROLLER_BASE_CLASSES:
|
|
590
|
+
return True
|
|
591
|
+
# Convention-based detection: a class named `XxxController` with at
|
|
592
|
+
# least one base class and one action-shaped method is a controller.
|
|
593
|
+
# This is how ASP.NET's runtime discovers controllers when intermediate
|
|
594
|
+
# base classes (`BasePublicController : Controller`) hide the well-
|
|
595
|
+
# known base from a single-file view of the code.
|
|
596
|
+
if (
|
|
597
|
+
cls.name.endswith("Controller")
|
|
598
|
+
and cls.name != "Controller"
|
|
599
|
+
and cls.base_classes
|
|
600
|
+
and any(self._is_action_return_type(m.return_type) for m in cls.methods)
|
|
601
|
+
):
|
|
602
|
+
return True
|
|
603
|
+
return False
|
|
604
|
+
|
|
605
|
+
def _build_iendpoint_class_map(self, parsed_file: ParsedFile) -> dict[str, str]:
|
|
606
|
+
"""
|
|
607
|
+
For every class implementing `IEndpoint<...>` (the MinimalApi.Endpoint
|
|
608
|
+
library pattern), return `{ClassName: HandlerMethodName}`.
|
|
609
|
+
|
|
610
|
+
Convention: the handler is the public method named ``HandleAsync``;
|
|
611
|
+
if no such method exists we fall back to the class name itself so
|
|
612
|
+
the caller still gets a class-level attribution.
|
|
613
|
+
"""
|
|
614
|
+
mapping: dict[str, str] = {}
|
|
615
|
+
for cls in parsed_file.classes:
|
|
616
|
+
# The parser strips generic arguments from base_classes, so we
|
|
617
|
+
# match on the simple name `IEndpoint` regardless of arity.
|
|
618
|
+
if not any(base.rsplit(".", 1)[-1] == "IEndpoint" for base in cls.base_classes):
|
|
619
|
+
continue
|
|
620
|
+
handler_method = next(
|
|
621
|
+
(m.name for m in cls.methods if m.name == "HandleAsync"),
|
|
622
|
+
None,
|
|
623
|
+
)
|
|
624
|
+
mapping[cls.name] = f"{cls.name}.{handler_method}" if handler_method else cls.name
|
|
625
|
+
return mapping
|
|
626
|
+
|
|
627
|
+
def _project_maproute_overrides(
|
|
628
|
+
self,
|
|
629
|
+
context: AnalysisContext | None,
|
|
630
|
+
parsed_file: ParsedFile,
|
|
631
|
+
) -> dict[tuple[str, str], str]:
|
|
632
|
+
"""
|
|
633
|
+
Build `{(Controller, Action): explicit_url}` from every
|
|
634
|
+
`routes.MapRoute(...)` / `MapLocalizedRoute(...)` call across the
|
|
635
|
+
project. These central registrations override the conventional
|
|
636
|
+
`/{Controller}/{Action}` path — e.g. `ShoppingCartController.Cart`
|
|
637
|
+
becomes `/cart/`, not `/ShoppingCart/Cart`.
|
|
638
|
+
"""
|
|
639
|
+
if context is not None:
|
|
640
|
+
key = id(context)
|
|
641
|
+
cached = self._maproute_overrides_cache.get(key)
|
|
642
|
+
if cached is not None:
|
|
643
|
+
return cached
|
|
644
|
+
merged: dict[tuple[str, str], str] = {}
|
|
645
|
+
for pf in context.all_parsed_files or []:
|
|
646
|
+
merged.update(_scan_maproute_registrations(pf))
|
|
647
|
+
self._maproute_overrides_cache[key] = merged
|
|
648
|
+
return merged
|
|
649
|
+
return _scan_maproute_registrations(parsed_file)
|
|
650
|
+
|
|
651
|
+
def _project_uses_slugify(
|
|
652
|
+
self,
|
|
653
|
+
context: AnalysisContext | None,
|
|
654
|
+
parsed_file: ParsedFile,
|
|
655
|
+
) -> bool:
|
|
656
|
+
"""
|
|
657
|
+
Detect whether the project registers `SlugifyParameterTransformer` /
|
|
658
|
+
a `:slugify=` constraint on the default MVC route. The signal lives
|
|
659
|
+
in Program.cs (route registration) but applies to controllers in
|
|
660
|
+
other files — so we scan the AnalysisContext's full file list, with
|
|
661
|
+
a fallback to the current file when no context is available.
|
|
662
|
+
"""
|
|
663
|
+
if context is not None:
|
|
664
|
+
key = id(context)
|
|
665
|
+
cached = self._slugify_active_cache.get(key)
|
|
666
|
+
if cached is not None:
|
|
667
|
+
return cached
|
|
668
|
+
active = any(_file_registers_slugify(pf) for pf in (context.all_parsed_files or []))
|
|
669
|
+
self._slugify_active_cache[key] = active
|
|
670
|
+
return active
|
|
671
|
+
# No context (unit-test invocation): only the current file is visible.
|
|
672
|
+
return _file_registers_slugify(parsed_file)
|
|
673
|
+
|
|
674
|
+
def _iendpoint_handler_for_call(
|
|
675
|
+
self,
|
|
676
|
+
call: ParsedCallSite,
|
|
677
|
+
iendpoint_classes: dict[str, str],
|
|
678
|
+
) -> str | None:
|
|
679
|
+
"""If `call` lives inside an `AddRoute` method on an IEndpoint class,
|
|
680
|
+
return the dotted handler attribution. Otherwise None."""
|
|
681
|
+
caller = call.caller_function
|
|
682
|
+
if caller is None or caller.name != "AddRoute":
|
|
683
|
+
return None
|
|
684
|
+
# caller.module is the dotted class path; we only care about the leaf.
|
|
685
|
+
enclosing_class = caller.module.rsplit(".", 1)[-1] if caller.module else ""
|
|
686
|
+
return iendpoint_classes.get(enclosing_class)
|
|
687
|
+
|
|
688
|
+
def _is_conventional_action(
|
|
689
|
+
self,
|
|
690
|
+
method: ParsedFunction,
|
|
691
|
+
cls: ParsedClass | None = None,
|
|
692
|
+
) -> bool:
|
|
693
|
+
"""
|
|
694
|
+
Whether a method on a conventional-routing controller should be treated
|
|
695
|
+
as an action (i.e. emitted as a GET route in the absence of an explicit
|
|
696
|
+
[HttpVerb] attribute).
|
|
697
|
+
|
|
698
|
+
MVC: the C# parser doesn't expose visibility, so visibility is
|
|
699
|
+
approximated by return-type filtering — real actions return one of
|
|
700
|
+
the well-known IActionResult-family types, view-model-returning
|
|
701
|
+
helpers fail the check.
|
|
702
|
+
|
|
703
|
+
Web API 2 (`ApiController` subclass): the convention is different.
|
|
704
|
+
Every public action returns *something* — the framework serializes
|
|
705
|
+
whatever you give it (IEnumerable<T>, POCO, IHttpActionResult, …).
|
|
706
|
+
Visibility-by-return-type doesn't work, so we accept any non-void
|
|
707
|
+
return type on an ApiController subclass.
|
|
708
|
+
"""
|
|
709
|
+
if method.binding != "instance" or method.is_abstract:
|
|
710
|
+
return False
|
|
711
|
+
if method.name in _OBJECT_METHOD_NAMES:
|
|
712
|
+
return False
|
|
713
|
+
if any(d.name in _NON_ROUTABLE_ATTRS for d in method.decorators):
|
|
714
|
+
return False
|
|
715
|
+
# Web API 2: any non-void, non-junk return is an action — but
|
|
716
|
+
# only when the method is `public` AND bound to an HTTP verb.
|
|
717
|
+
if cls is not None and self._is_web_api_controller(cls):
|
|
718
|
+
ret = (method.return_type or "").strip()
|
|
719
|
+
if not ret or ret == "void":
|
|
720
|
+
return False
|
|
721
|
+
# Parser sometimes captures the leading `//` comment as the return
|
|
722
|
+
# type when there's a comment immediately above the method. Reject
|
|
723
|
+
# those so we don't accept comment-as-return-type as valid.
|
|
724
|
+
if ret.startswith("//") or ret.startswith("/*"):
|
|
725
|
+
return False
|
|
726
|
+
# Web API 2 only routes `public` methods. ParsedFunction
|
|
727
|
+
# doesn't track access modifiers; we read the source line
|
|
728
|
+
# for the method declaration and look for `public`. In C#,
|
|
729
|
+
# methods in a class default to `private` when no modifier
|
|
730
|
+
# is present, so absence-of-`public` is non-routable.
|
|
731
|
+
if not _method_is_public_csharp(method):
|
|
732
|
+
return False
|
|
733
|
+
# Web API 2 action selection requires the method bind to an
|
|
734
|
+
# HTTP verb — either via an explicit attribute
|
|
735
|
+
# (`[HttpGet]`/`[HttpPost]`/...) or via a method-name prefix
|
|
736
|
+
# (`Get*`/`Post*`/...). Methods without either are
|
|
737
|
+
# unreachable through Web API 2 conventional routing.
|
|
738
|
+
has_verb_attr = any(d.name in _HTTP_METHOD_ATTRS for d in method.decorators)
|
|
739
|
+
if has_verb_attr:
|
|
740
|
+
return True
|
|
741
|
+
has_verb_prefix = any(
|
|
742
|
+
method.name.startswith(prefix) for prefix, _ in _WEBAPI_VERB_PREFIXES
|
|
743
|
+
)
|
|
744
|
+
return has_verb_prefix
|
|
745
|
+
return self._is_action_return_type(method.return_type)
|
|
746
|
+
|
|
747
|
+
def _is_web_api_controller(self, cls: ParsedClass) -> bool:
|
|
748
|
+
"""True if the class directly inherits from a Web API 2 base."""
|
|
749
|
+
for base in cls.base_classes:
|
|
750
|
+
simple = base.rsplit(".", 1)[-1]
|
|
751
|
+
if simple in _WEB_API_CONTROLLER_BASES:
|
|
752
|
+
return True
|
|
753
|
+
return False
|
|
754
|
+
|
|
755
|
+
def _webapi_verb_for_method(self, method_name: str) -> HttpMethod:
|
|
756
|
+
"""Resolve a Web API 2 method's HTTP verb from its name prefix.
|
|
757
|
+
|
|
758
|
+
`Get*` → GET, `Post*` → POST, `Put*` → PUT, `Delete*` → DELETE,
|
|
759
|
+
`Patch*` → PATCH, `Head*` → HEAD, `Options*` → OPTIONS.
|
|
760
|
+
Unrecognised prefixes default to GET — matching Web API 2's
|
|
761
|
+
fallback for unmatched methods on conventional routing.
|
|
762
|
+
"""
|
|
763
|
+
for prefix, verb in _WEBAPI_VERB_PREFIXES:
|
|
764
|
+
if method_name.startswith(prefix):
|
|
765
|
+
return verb
|
|
766
|
+
return HttpMethod.GET
|
|
767
|
+
|
|
768
|
+
def _webapi_path_suffix(self, method: ParsedFunction) -> str:
|
|
769
|
+
"""
|
|
770
|
+
Return the per-method path segment for a Web API 2 conventional route.
|
|
771
|
+
|
|
772
|
+
The default Web API 2 route template is `api/{controller}/{id?}` —
|
|
773
|
+
the `{id}` route token binds to a method parameter literally named
|
|
774
|
+
`id`. Primitive parameters with OTHER names (`start`, `notify`,
|
|
775
|
+
`productId`, etc.) bind from the QUERY STRING and don't contribute
|
|
776
|
+
a path segment — so the URL is just `/api/{controller}`.
|
|
777
|
+
|
|
778
|
+
Returns `"{id}"` when a primitive `id` parameter is present;
|
|
779
|
+
otherwise empty string.
|
|
780
|
+
|
|
781
|
+
(Earlier behaviour appended `{<param>}` for the first primitive
|
|
782
|
+
regardless of name, producing URLs like `/api/Contacts/{start}`
|
|
783
|
+
for `GetContacts(int start, ...)` — a route Web API 2's runtime
|
|
784
|
+
wouldn't actually dispatch.)
|
|
785
|
+
"""
|
|
786
|
+
for param in method.parameters:
|
|
787
|
+
if param.name != "id":
|
|
788
|
+
continue
|
|
789
|
+
type_name = (param.type_annotation or "").strip()
|
|
790
|
+
if not type_name:
|
|
791
|
+
continue
|
|
792
|
+
base = type_name.split("<")[0].strip().rstrip("?")
|
|
793
|
+
simple = base.rsplit(".", 1)[-1]
|
|
794
|
+
if simple in _PRIMITIVE_PARAM_TYPES:
|
|
795
|
+
return "{id}"
|
|
796
|
+
return ""
|
|
797
|
+
|
|
798
|
+
def _is_action_return_type(self, return_type: str | None) -> bool:
|
|
799
|
+
"""Return True for IActionResult-family return types (Task<…> aware)."""
|
|
800
|
+
if not return_type:
|
|
801
|
+
return False
|
|
802
|
+
ret = return_type.strip()
|
|
803
|
+
# Bare Task / ValueTask with no generic = async void action.
|
|
804
|
+
if ret in ("Task", "ValueTask"):
|
|
805
|
+
return True
|
|
806
|
+
for wrapper in ("Task<", "ValueTask<"):
|
|
807
|
+
if ret.startswith(wrapper) and ret.endswith(">"):
|
|
808
|
+
return self._is_action_return_type(ret[len(wrapper) : -1].strip())
|
|
809
|
+
base = ret.split("<")[0].strip().rsplit(".", 1)[-1]
|
|
810
|
+
return base in _ACTION_RETURN_TYPE_NAMES
|
|
811
|
+
|
|
812
|
+
def _get_class_prefix(
|
|
813
|
+
self,
|
|
814
|
+
cls: ParsedClass,
|
|
815
|
+
parsed_file: ParsedFile,
|
|
816
|
+
slugify: bool = False,
|
|
817
|
+
) -> str:
|
|
818
|
+
"""
|
|
819
|
+
Resolve [Route("prefix")] on the controller class.
|
|
820
|
+
|
|
821
|
+
Handles the [controller] token — replaced by the class name without
|
|
822
|
+
the "Controller" suffix. When `slugify=True` (project registers
|
|
823
|
+
SlugifyParameterTransformer), the token expansion and conventional
|
|
824
|
+
fallback are kebab-cased: ``UsersController`` → ``/users``,
|
|
825
|
+
``MyAdminController`` → ``/my-admin``.
|
|
826
|
+
"""
|
|
827
|
+
controller_name = cls.name
|
|
828
|
+
if controller_name.endswith("Controller"):
|
|
829
|
+
controller_name = controller_name[: -len("Controller")]
|
|
830
|
+
token_value = _slugify(controller_name) if slugify else controller_name
|
|
831
|
+
|
|
832
|
+
# `[RoutePrefix("...")]` (Web API 2 attribute routing) takes priority
|
|
833
|
+
# over `[Route]` — it's a hard prefix that combines with method-level
|
|
834
|
+
# `[Route]` to form the full URL. A `[Route]` *also* on the class is
|
|
835
|
+
# rare in this scenario; if present, the RoutePrefix wins.
|
|
836
|
+
for dec in cls.decorators:
|
|
837
|
+
if dec.name == "RoutePrefix":
|
|
838
|
+
path = self._dec_path(dec)
|
|
839
|
+
if path:
|
|
840
|
+
path = path.replace("[controller]", token_value)
|
|
841
|
+
path = path.replace("[Controller]", token_value)
|
|
842
|
+
return ("/" + path.strip("/")).rstrip("/")
|
|
843
|
+
|
|
844
|
+
for dec in cls.decorators:
|
|
845
|
+
if dec.name == "Route":
|
|
846
|
+
path = self._dec_path(dec)
|
|
847
|
+
if path:
|
|
848
|
+
path = path.replace("[controller]", token_value)
|
|
849
|
+
path = path.replace("[Controller]", token_value)
|
|
850
|
+
return path.rstrip("/")
|
|
851
|
+
# Conventional routing: no [Route] but the class is recognized as a
|
|
852
|
+
# controller. Web API 2 ApiController subclasses get the framework's
|
|
853
|
+
# `api/{controller}` default; MVC Controllers get `/{controller}`.
|
|
854
|
+
if self._is_web_api_controller(cls):
|
|
855
|
+
return f"/api/{token_value}"
|
|
856
|
+
if self._is_controller(cls):
|
|
857
|
+
return f"/{token_value}"
|
|
858
|
+
return ""
|
|
859
|
+
|
|
860
|
+
def _extract_routes_from_method(
|
|
861
|
+
self,
|
|
862
|
+
method: ParsedFunction,
|
|
863
|
+
class_prefix: str,
|
|
864
|
+
cls: ParsedClass,
|
|
865
|
+
parsed_file: ParsedFile,
|
|
866
|
+
context: AnalysisContext | None,
|
|
867
|
+
is_conventional_mvc: bool = False,
|
|
868
|
+
slugify: bool = False,
|
|
869
|
+
maproute_overrides: dict[tuple[str, str], str] | None = None,
|
|
870
|
+
) -> list[ExtractedRoute]:
|
|
871
|
+
routes: list[ExtractedRoute] = []
|
|
872
|
+
|
|
873
|
+
# [ChildActionOnly] / [NonAction] mark the method as non-HTTP — used
|
|
874
|
+
# for partial-view rendering (Html.Action) and helper methods. ASP.NET
|
|
875
|
+
# MVC's runtime skips routing entirely for these, and so should we —
|
|
876
|
+
# otherwise the conventional-routing path emits hundreds of false
|
|
877
|
+
# positives (nopCommerce 3.90 has 204 [ChildActionOnly] methods).
|
|
878
|
+
if any(d.name in _NON_ROUTABLE_ATTRS for d in method.decorators):
|
|
879
|
+
return routes
|
|
880
|
+
|
|
881
|
+
# Collect HTTP-method attributes and optional [Route] on this method
|
|
882
|
+
method_paths: list[str] = []
|
|
883
|
+
http_methods: list[HttpMethod] = []
|
|
884
|
+
produces: str | None = None
|
|
885
|
+
consumes: str | None = None
|
|
886
|
+
response_status = 200
|
|
887
|
+
explicit_method_path = False # True when an [HttpVerb("path")] carried an explicit path
|
|
888
|
+
|
|
889
|
+
for dec in method.decorators:
|
|
890
|
+
hm = _HTTP_METHOD_ATTRS.get(dec.name)
|
|
891
|
+
if hm is not None:
|
|
892
|
+
http_methods.append(hm)
|
|
893
|
+
# Only record a path from [HttpMethod] when it carries an explicit
|
|
894
|
+
# path argument (e.g. [HttpGet("{id}")]). When there is no argument
|
|
895
|
+
# (e.g. bare [HttpPost]) we leave method_paths unchanged so that
|
|
896
|
+
# any earlier [Route("…")] path is used instead of generating a
|
|
897
|
+
# spurious duplicate route with an empty segment.
|
|
898
|
+
local = self._dec_path(dec)
|
|
899
|
+
if local is not None and local not in method_paths:
|
|
900
|
+
method_paths.append(local)
|
|
901
|
+
explicit_method_path = True
|
|
902
|
+
|
|
903
|
+
elif dec.name == "Route":
|
|
904
|
+
# [Route] on a method contributes a path but not an HTTP method.
|
|
905
|
+
# Counts as an explicit method path — when the class has no
|
|
906
|
+
# routing attribute, this is the FULL URL (no conventional
|
|
907
|
+
# `/api/{controller}` prefix prepended).
|
|
908
|
+
local = self._dec_path(dec) or ""
|
|
909
|
+
if local not in method_paths:
|
|
910
|
+
method_paths.append(local)
|
|
911
|
+
explicit_method_path = True
|
|
912
|
+
|
|
913
|
+
elif dec.name == "Produces":
|
|
914
|
+
produces = dec.positional_args[0] if dec.positional_args else None
|
|
915
|
+
elif dec.name == "Consumes":
|
|
916
|
+
consumes = dec.positional_args[0] if dec.positional_args else None
|
|
917
|
+
elif dec.name == "ProducesResponseType":
|
|
918
|
+
response_status = self._extract_response_status(dec)
|
|
919
|
+
|
|
920
|
+
if not http_methods:
|
|
921
|
+
# Methods without an explicit [HttpVerb] attribute can still bind
|
|
922
|
+
# to a verb in two cases:
|
|
923
|
+
#
|
|
924
|
+
# 1. Web API 2 (ApiController): the method-name prefix selects the
|
|
925
|
+
# verb (Get*, Post*, Put*, …). This is unconditional in Web
|
|
926
|
+
# API 2 — it applies *whether or not* the class carries
|
|
927
|
+
# [Route] / [RoutePrefix]. LPL-style controllers commonly
|
|
928
|
+
# decorate the class with [RoutePrefix("api/v1/orders")] and
|
|
929
|
+
# leave methods without [HttpVerb]; the runtime still binds
|
|
930
|
+
# by name.
|
|
931
|
+
#
|
|
932
|
+
# 2. MVC (Controller) with conventional routing (no class-level
|
|
933
|
+
# [Route]): the method is an action that defaults to GET.
|
|
934
|
+
if self._is_web_api_controller(cls) and self._is_conventional_action(method, cls):
|
|
935
|
+
http_methods = [self._webapi_verb_for_method(method.name)]
|
|
936
|
+
elif is_conventional_mvc and self._is_conventional_action(method, cls):
|
|
937
|
+
http_methods = [HttpMethod.GET]
|
|
938
|
+
else:
|
|
939
|
+
return routes
|
|
940
|
+
|
|
941
|
+
if not method_paths:
|
|
942
|
+
if is_conventional_mvc:
|
|
943
|
+
if self._is_web_api_controller(cls):
|
|
944
|
+
# Web API 2 default route template is `api/{controller}/{id?}`
|
|
945
|
+
# — there's no {action} segment. Method name selected the
|
|
946
|
+
# verb already (PR #18). Append `{id}` when the method
|
|
947
|
+
# takes an id-like parameter; otherwise the path is the
|
|
948
|
+
# bare class prefix.
|
|
949
|
+
method_paths = [self._webapi_path_suffix(method)]
|
|
950
|
+
else:
|
|
951
|
+
# Conventional MVC: use method name as action;
|
|
952
|
+
# Index() → controller root. Slug-case when registered.
|
|
953
|
+
action = (
|
|
954
|
+
""
|
|
955
|
+
if method.name == "Index"
|
|
956
|
+
else (_slugify(method.name) if slugify else method.name)
|
|
957
|
+
)
|
|
958
|
+
method_paths = [action]
|
|
959
|
+
else:
|
|
960
|
+
method_paths = [""]
|
|
961
|
+
|
|
962
|
+
auth_refs = self._method_auth_refs(cls, method)
|
|
963
|
+
|
|
964
|
+
# Resolve the controller name once for MapRoute-override lookup.
|
|
965
|
+
controller_short_name = cls.name
|
|
966
|
+
if controller_short_name.endswith("Controller"):
|
|
967
|
+
controller_short_name = controller_short_name[: -len("Controller")]
|
|
968
|
+
|
|
969
|
+
# When the class has no routing attribute at all (no `[Route]`,
|
|
970
|
+
# no `[RoutePrefix]`), a method's explicit `[Route]` or
|
|
971
|
+
# `[HttpVerb("path")]` IS the full URL — the conventional
|
|
972
|
+
# `/api/{controller}` fallback prefix doesn't apply. When the
|
|
973
|
+
# class HAS `[RoutePrefix]` (or class-level `[Route]`), the
|
|
974
|
+
# method route is appended to that prefix.
|
|
975
|
+
class_has_routing_attr = any(d.name in ("Route", "RoutePrefix") for d in cls.decorators)
|
|
976
|
+
|
|
977
|
+
for http_method in http_methods:
|
|
978
|
+
for local_path in method_paths:
|
|
979
|
+
# Ardalis/single-method endpoint pattern: class has no
|
|
980
|
+
# routing attribute (is_conventional_mvc=True AND no
|
|
981
|
+
# `[RoutePrefix]`) and the method carries its own explicit
|
|
982
|
+
# route path. The method path IS the full route — no
|
|
983
|
+
# controller-name prefix is applied.
|
|
984
|
+
if (
|
|
985
|
+
is_conventional_mvc
|
|
986
|
+
and explicit_method_path
|
|
987
|
+
and local_path
|
|
988
|
+
and not class_has_routing_attr
|
|
989
|
+
):
|
|
990
|
+
full_path = local_path if local_path.startswith("/") else "/" + local_path
|
|
991
|
+
else:
|
|
992
|
+
full_path = self._join_paths(class_prefix, local_path)
|
|
993
|
+
# Resolve [action] / [Action] token inserted by [Route("[controller]/[action]")]
|
|
994
|
+
action_token = _slugify(method.name) if slugify else method.name
|
|
995
|
+
full_path = full_path.replace("[action]", action_token).replace(
|
|
996
|
+
"[Action]", action_token
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
# Central registrations (routes.MapRoute / MapLocalizedRoute)
|
|
1000
|
+
# override the conventional `/{Controller}/{Action}` path with
|
|
1001
|
+
# whatever explicit URL was registered for this (controller,
|
|
1002
|
+
# action) pair. Only consult for conventional routing where
|
|
1003
|
+
# no explicit method path was set — attribute-routed methods
|
|
1004
|
+
# already carry the truth in `local_path`.
|
|
1005
|
+
if is_conventional_mvc and not explicit_method_path and maproute_overrides:
|
|
1006
|
+
override = maproute_overrides.get((controller_short_name, method.name))
|
|
1007
|
+
if override is not None:
|
|
1008
|
+
full_path = override
|
|
1009
|
+
|
|
1010
|
+
full_path = self._normalize_path(full_path)
|
|
1011
|
+
|
|
1012
|
+
path_params, query_params, header_params, cookie_params, body = (
|
|
1013
|
+
self._extract_params(method, full_path, context, parsed_file)
|
|
1014
|
+
)
|
|
1015
|
+
|
|
1016
|
+
if consumes and body is not None:
|
|
1017
|
+
body = ExtractedBody(
|
|
1018
|
+
content_type=consumes,
|
|
1019
|
+
model_name=body.model_name,
|
|
1020
|
+
model_fields=body.model_fields,
|
|
1021
|
+
required=body.required,
|
|
1022
|
+
)
|
|
1023
|
+
|
|
1024
|
+
response = ExtractedResponse(
|
|
1025
|
+
model_name=method.return_type,
|
|
1026
|
+
status_code=response_status,
|
|
1027
|
+
content_type=produces,
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
routes.append(
|
|
1031
|
+
ExtractedRoute(
|
|
1032
|
+
method=http_method,
|
|
1033
|
+
path=full_path,
|
|
1034
|
+
handler_function=method.qualified_name,
|
|
1035
|
+
handler_location=method.location,
|
|
1036
|
+
path_params=path_params,
|
|
1037
|
+
query_params=query_params,
|
|
1038
|
+
header_params=header_params,
|
|
1039
|
+
cookie_params=cookie_params,
|
|
1040
|
+
body=body,
|
|
1041
|
+
response=response,
|
|
1042
|
+
tags=[cls.name],
|
|
1043
|
+
dependency_refs=auth_refs,
|
|
1044
|
+
confidence=Confidence.HIGH,
|
|
1045
|
+
kind="http",
|
|
1046
|
+
)
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
return routes
|
|
1050
|
+
|
|
1051
|
+
# -------------------------------------------------------------------------
|
|
1052
|
+
# Razor Pages support
|
|
1053
|
+
# -------------------------------------------------------------------------
|
|
1054
|
+
|
|
1055
|
+
# Handler name prefix → HTTP method (case-sensitive; Async suffix stripped first)
|
|
1056
|
+
_RAZOR_HANDLER_VERBS: ClassVar[dict[str, HttpMethod]] = {
|
|
1057
|
+
"OnGet": HttpMethod.GET,
|
|
1058
|
+
"OnPost": HttpMethod.POST,
|
|
1059
|
+
"OnPut": HttpMethod.PUT,
|
|
1060
|
+
"OnDelete": HttpMethod.DELETE,
|
|
1061
|
+
"OnPatch": HttpMethod.PATCH,
|
|
1062
|
+
"OnHead": HttpMethod.HEAD,
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
def _is_razor_page(self, cls: ParsedClass) -> bool:
|
|
1066
|
+
return any(base.rsplit(".", 1)[-1] == "PageModel" for base in cls.base_classes)
|
|
1067
|
+
|
|
1068
|
+
def _derive_razor_page_path(self, file_path: str, cls: ParsedClass) -> str:
|
|
1069
|
+
"""
|
|
1070
|
+
Derive the Razor Pages URL from the source file path.
|
|
1071
|
+
|
|
1072
|
+
Conventions:
|
|
1073
|
+
- ``{root}/Pages/{Rel}.cshtml.cs`` → ``/{Rel}``
|
|
1074
|
+
- ``{root}/Areas/{Name}/Pages/{Rel}.cshtml.cs`` → ``/{Name}/{Rel}``
|
|
1075
|
+
- ``Index`` as the final segment is stripped (maps to parent dir).
|
|
1076
|
+
- ``[Route]`` on the class overrides the derived path entirely.
|
|
1077
|
+
|
|
1078
|
+
Falls back to ``/{ClassName}`` when the Pages directory is not found.
|
|
1079
|
+
"""
|
|
1080
|
+
# Explicit [Route] on the class wins
|
|
1081
|
+
for dec in cls.decorators:
|
|
1082
|
+
if dec.name == "Route":
|
|
1083
|
+
raw = self._dec_path(dec)
|
|
1084
|
+
if raw:
|
|
1085
|
+
return raw if raw.startswith("/") else "/" + raw
|
|
1086
|
+
|
|
1087
|
+
# Normalise separators so we can search consistently
|
|
1088
|
+
norm_path = file_path.replace("\\", "/")
|
|
1089
|
+
# Strip .cshtml.cs or .cshtml extension
|
|
1090
|
+
for ext in (".cshtml.cs", ".cshtml"):
|
|
1091
|
+
if norm_path.endswith(ext):
|
|
1092
|
+
norm_path = norm_path[: -len(ext)]
|
|
1093
|
+
break
|
|
1094
|
+
|
|
1095
|
+
# Try Areas/{AreaName}/Pages/{Rel}
|
|
1096
|
+
area_marker = "/Areas/"
|
|
1097
|
+
pages_marker = "/Pages/"
|
|
1098
|
+
area_idx = norm_path.find(area_marker)
|
|
1099
|
+
if area_idx != -1:
|
|
1100
|
+
rest = norm_path[area_idx + len(area_marker) :]
|
|
1101
|
+
# rest = "{AreaName}/Pages/{Rel}"
|
|
1102
|
+
pages_idx = rest.find(pages_marker)
|
|
1103
|
+
if pages_idx != -1:
|
|
1104
|
+
area_name = rest[:pages_idx]
|
|
1105
|
+
rel = rest[pages_idx + len(pages_marker) :]
|
|
1106
|
+
path = f"/{area_name}/{rel}"
|
|
1107
|
+
return self._razor_strip_index(self._normalize_path(path))
|
|
1108
|
+
|
|
1109
|
+
# Regular Pages/{Rel}
|
|
1110
|
+
pages_idx = norm_path.find(pages_marker)
|
|
1111
|
+
if pages_idx != -1:
|
|
1112
|
+
rel = norm_path[pages_idx + len(pages_marker) :]
|
|
1113
|
+
path = "/" + rel
|
|
1114
|
+
return self._razor_strip_index(self._normalize_path(path))
|
|
1115
|
+
|
|
1116
|
+
# Fallback: use class name, strip "Model" suffix
|
|
1117
|
+
name = cls.name
|
|
1118
|
+
if name.endswith("Model"):
|
|
1119
|
+
name = name[: -len("Model")]
|
|
1120
|
+
return "/" + name
|
|
1121
|
+
|
|
1122
|
+
@staticmethod
|
|
1123
|
+
def _razor_strip_index(path: str) -> str:
|
|
1124
|
+
"""Strip a trailing ``/Index`` segment (Razor Pages convention)."""
|
|
1125
|
+
if path.endswith("/Index"):
|
|
1126
|
+
stripped = path[: -len("/Index")]
|
|
1127
|
+
return stripped or "/"
|
|
1128
|
+
return path
|
|
1129
|
+
|
|
1130
|
+
def _extract_razor_page_routes(
|
|
1131
|
+
self,
|
|
1132
|
+
parsed_file: ParsedFile,
|
|
1133
|
+
context: AnalysisContext | None = None,
|
|
1134
|
+
) -> list[ExtractedRoute]:
|
|
1135
|
+
"""
|
|
1136
|
+
Extract routes from Razor Pages PageModel classes.
|
|
1137
|
+
|
|
1138
|
+
Handler naming convention: ``On{Verb}[HandlerName][Async]``
|
|
1139
|
+
|
|
1140
|
+
- ``OnGet`` → GET (no named handler)
|
|
1141
|
+
- ``OnPost`` → POST (no named handler)
|
|
1142
|
+
- ``OnPostUpdate`` → POST with query param ``handler=Update``
|
|
1143
|
+
- ``OnGetAsync`` → GET (Async suffix stripped before matching)
|
|
1144
|
+
"""
|
|
1145
|
+
routes: list[ExtractedRoute] = []
|
|
1146
|
+
file_path = str(parsed_file.path)
|
|
1147
|
+
|
|
1148
|
+
for cls in parsed_file.classes:
|
|
1149
|
+
if not self._is_razor_page(cls):
|
|
1150
|
+
continue
|
|
1151
|
+
|
|
1152
|
+
page_path = self._derive_razor_page_path(file_path, cls)
|
|
1153
|
+
|
|
1154
|
+
# Class-level auth applies to all handlers unless the method overrides
|
|
1155
|
+
class_allow_anon = any(d.name == "AllowAnonymous" for d in cls.decorators)
|
|
1156
|
+
class_authorize = any(d.name == "Authorize" for d in cls.decorators)
|
|
1157
|
+
|
|
1158
|
+
for method in cls.methods:
|
|
1159
|
+
name = method.name
|
|
1160
|
+
# Strip Async suffix before pattern matching
|
|
1161
|
+
if name.endswith("Async"):
|
|
1162
|
+
name = name[: -len("Async")]
|
|
1163
|
+
|
|
1164
|
+
http_method: HttpMethod | None = None
|
|
1165
|
+
handler_name: str | None = None
|
|
1166
|
+
|
|
1167
|
+
for verb_prefix, hm in self._RAZOR_HANDLER_VERBS.items():
|
|
1168
|
+
if name == verb_prefix:
|
|
1169
|
+
http_method = hm
|
|
1170
|
+
break
|
|
1171
|
+
if name.startswith(verb_prefix) and len(name) > len(verb_prefix):
|
|
1172
|
+
http_method = hm
|
|
1173
|
+
handler_name = name[len(verb_prefix) :]
|
|
1174
|
+
break
|
|
1175
|
+
|
|
1176
|
+
if http_method is None:
|
|
1177
|
+
continue
|
|
1178
|
+
|
|
1179
|
+
# Named handler → append ?handler=Name as a query param
|
|
1180
|
+
query_params: list[ExtractedParameter] = []
|
|
1181
|
+
if handler_name:
|
|
1182
|
+
query_params.append(
|
|
1183
|
+
ExtractedParameter(
|
|
1184
|
+
name="handler",
|
|
1185
|
+
location=ParameterLocation.QUERY,
|
|
1186
|
+
type_annotation="string",
|
|
1187
|
+
required=True,
|
|
1188
|
+
default_value=handler_name,
|
|
1189
|
+
)
|
|
1190
|
+
)
|
|
1191
|
+
|
|
1192
|
+
# Auth: method-level overrides class-level
|
|
1193
|
+
method_allow_anon = any(d.name == "AllowAnonymous" for d in method.decorators)
|
|
1194
|
+
method_authorize = any(d.name == "Authorize" for d in method.decorators)
|
|
1195
|
+
|
|
1196
|
+
if method_allow_anon or class_allow_anon:
|
|
1197
|
+
auth_refs: list[str] = []
|
|
1198
|
+
elif method_authorize:
|
|
1199
|
+
auth_refs = [f"{cls.name}.{method.name}"]
|
|
1200
|
+
elif class_authorize:
|
|
1201
|
+
auth_refs = [cls.name]
|
|
1202
|
+
else:
|
|
1203
|
+
auth_refs = []
|
|
1204
|
+
|
|
1205
|
+
# Path params from [BindProperty(SupportsGet=true)] or route template
|
|
1206
|
+
path_params = self._extract_path_params_from_template(page_path)
|
|
1207
|
+
|
|
1208
|
+
routes.append(
|
|
1209
|
+
ExtractedRoute(
|
|
1210
|
+
method=http_method,
|
|
1211
|
+
path=page_path,
|
|
1212
|
+
handler_function=_str_to_qname(f"{cls.name}.{method.name}"),
|
|
1213
|
+
handler_location=method.location,
|
|
1214
|
+
path_params=path_params,
|
|
1215
|
+
query_params=query_params,
|
|
1216
|
+
header_params=[],
|
|
1217
|
+
cookie_params=[],
|
|
1218
|
+
body=None,
|
|
1219
|
+
response=ExtractedResponse(
|
|
1220
|
+
status_code=200,
|
|
1221
|
+
model_name=method.return_type,
|
|
1222
|
+
),
|
|
1223
|
+
tags=["razor-pages"],
|
|
1224
|
+
dependency_refs=auth_refs,
|
|
1225
|
+
confidence=Confidence.HIGH,
|
|
1226
|
+
kind="razor-page",
|
|
1227
|
+
)
|
|
1228
|
+
)
|
|
1229
|
+
|
|
1230
|
+
return routes
|
|
1231
|
+
|
|
1232
|
+
def _extract_minimal_api_routes(
|
|
1233
|
+
self,
|
|
1234
|
+
parsed_file: ParsedFile,
|
|
1235
|
+
context: AnalysisContext | None = None,
|
|
1236
|
+
) -> list[ExtractedRoute]:
|
|
1237
|
+
"""
|
|
1238
|
+
Detect minimal-API call sites: app.MapGet("/users/{id}", handler)
|
|
1239
|
+
|
|
1240
|
+
Extracts:
|
|
1241
|
+
- Actual path from first string-literal argument (HIGH confidence)
|
|
1242
|
+
or falls back to "/[minimal-api]" when the path is dynamic (MEDIUM)
|
|
1243
|
+
- Path parameters derived from the path template
|
|
1244
|
+
- Handler function name from the second argument (method reference)
|
|
1245
|
+
or "<lambda>" for inline lambdas. When the call lives inside
|
|
1246
|
+
`AddRoute(IEndpointRouteBuilder)` on a class implementing
|
|
1247
|
+
`IEndpoint<...>` (the `MinimalApi.Endpoint` library pattern), the
|
|
1248
|
+
handler is attributed to `{ClassName}.HandleAsync` instead.
|
|
1249
|
+
- Auth chaining: .RequireAuthorization() / .AllowAnonymous() detected
|
|
1250
|
+
via receiver_expression line-number correlation set by the parser
|
|
1251
|
+
"""
|
|
1252
|
+
routes: list[ExtractedRoute] = []
|
|
1253
|
+
|
|
1254
|
+
# Gap 3: Build a name → ParsedFunction lookup for method-reference handlers
|
|
1255
|
+
_method_lookup: dict[str, ParsedFunction] = {}
|
|
1256
|
+
for _func in parsed_file.functions:
|
|
1257
|
+
_method_lookup[_func.name] = _func
|
|
1258
|
+
for _cls in parsed_file.classes:
|
|
1259
|
+
for _m in _cls.methods:
|
|
1260
|
+
_method_lookup[_m.name] = _m
|
|
1261
|
+
|
|
1262
|
+
# MinimalApi.Endpoint pattern: class → handler-method-name override.
|
|
1263
|
+
# Built once per file because most projects with this pattern have
|
|
1264
|
+
# one endpoint class per file.
|
|
1265
|
+
_iendpoint_classes: dict[str, str] = self._build_iendpoint_class_map(parsed_file)
|
|
1266
|
+
|
|
1267
|
+
# Separate Map* calls from auth/metadata chainers
|
|
1268
|
+
map_calls: list = []
|
|
1269
|
+
map_methods_calls: list = []
|
|
1270
|
+
# line → list of chainer method names that reference that line
|
|
1271
|
+
auth_chainers: dict[int, list[str]] = {}
|
|
1272
|
+
|
|
1273
|
+
for call in parsed_file.call_sites:
|
|
1274
|
+
method_name = call.callee_name.split(".")[-1]
|
|
1275
|
+
if method_name in _MINIMAL_API_METHODS:
|
|
1276
|
+
# Skip groupBuilder.Map* — handled by _extract_endpoint_group_routes
|
|
1277
|
+
if call.receiver_expression == "groupBuilder":
|
|
1278
|
+
continue
|
|
1279
|
+
map_calls.append(call)
|
|
1280
|
+
elif method_name == "MapMethods":
|
|
1281
|
+
# Skip groupBuilder.MapMethods
|
|
1282
|
+
if call.receiver_expression == "groupBuilder":
|
|
1283
|
+
continue
|
|
1284
|
+
map_methods_calls.append(call)
|
|
1285
|
+
elif (
|
|
1286
|
+
method_name in _MINIMAL_API_CHAINERS
|
|
1287
|
+
and call.receiver_expression
|
|
1288
|
+
and call.receiver_expression.startswith("line:")
|
|
1289
|
+
):
|
|
1290
|
+
try:
|
|
1291
|
+
target_line = int(call.receiver_expression[5:])
|
|
1292
|
+
auth_chainers.setdefault(target_line, []).append(method_name)
|
|
1293
|
+
except ValueError:
|
|
1294
|
+
pass
|
|
1295
|
+
|
|
1296
|
+
# ── Two-pass MapGroup inline prefix tracker ───────────────────────────
|
|
1297
|
+
# First pass: collect MapGroup calls → map receiver_expression + "_group"
|
|
1298
|
+
# to the resolved prefix string.
|
|
1299
|
+
# This handles: var api = app.MapGroup("/api"); api.MapGet("/users", ...)
|
|
1300
|
+
_map_group_prefixes: dict[str, str] = {}
|
|
1301
|
+
for call in parsed_file.call_sites:
|
|
1302
|
+
if call.callee_name.split(".")[-1] != "MapGroup":
|
|
1303
|
+
continue
|
|
1304
|
+
if not call.arguments or not call.arguments[0].is_literal:
|
|
1305
|
+
continue
|
|
1306
|
+
raw = str(call.arguments[0].literal_value or "")
|
|
1307
|
+
grp_prefix = "/" + raw.strip("/") if raw else ""
|
|
1308
|
+
# Map the receiver (e.g. "api", "app") to the group prefix
|
|
1309
|
+
recv = call.receiver_expression or ""
|
|
1310
|
+
if recv:
|
|
1311
|
+
_map_group_prefixes[recv + "_group"] = grp_prefix
|
|
1312
|
+
|
|
1313
|
+
for call in map_calls:
|
|
1314
|
+
method_name = call.callee_name.split(".")[-1]
|
|
1315
|
+
hm = _MINIMAL_API_METHODS[method_name]
|
|
1316
|
+
|
|
1317
|
+
# ── Path ────────────────────────────────────────────────────────
|
|
1318
|
+
path = "/[minimal-api]"
|
|
1319
|
+
if (
|
|
1320
|
+
call.arguments
|
|
1321
|
+
and call.arguments[0].is_literal
|
|
1322
|
+
and call.arguments[0].literal_type == "str"
|
|
1323
|
+
and call.arguments[0].literal_value
|
|
1324
|
+
):
|
|
1325
|
+
path = call.arguments[0].literal_value
|
|
1326
|
+
|
|
1327
|
+
# Strip route constraints: {id:int} → {id}
|
|
1328
|
+
path = self._normalize_path(path)
|
|
1329
|
+
# Normalise: all paths should start with /
|
|
1330
|
+
if path and path != "/[minimal-api]" and not path.startswith("/"):
|
|
1331
|
+
path = "/" + path
|
|
1332
|
+
|
|
1333
|
+
# Apply inline MapGroup prefix (same-file pattern)
|
|
1334
|
+
recv = call.receiver_expression or ""
|
|
1335
|
+
_inline_prefix = _map_group_prefixes.get(recv + "_group", "")
|
|
1336
|
+
if _inline_prefix:
|
|
1337
|
+
import re as _re
|
|
1338
|
+
|
|
1339
|
+
path = _inline_prefix.rstrip("/") + "/" + path.lstrip("/")
|
|
1340
|
+
path = _re.sub(r"/+", "/", path)
|
|
1341
|
+
|
|
1342
|
+
# ── Handler name ─────────────────────────────────────────────────
|
|
1343
|
+
handler_name = call.callee_name
|
|
1344
|
+
lambda_arg = None
|
|
1345
|
+
handler_method: ParsedFunction | None = None
|
|
1346
|
+
if len(call.arguments) > 1:
|
|
1347
|
+
arg1 = call.arguments[1]
|
|
1348
|
+
if arg1.is_variable and arg1.variable_name:
|
|
1349
|
+
handler_name = arg1.variable_name
|
|
1350
|
+
handler_method = _method_lookup.get(handler_name)
|
|
1351
|
+
elif arg1.is_expression and arg1.expression_text == "<lambda>":
|
|
1352
|
+
handler_name = f"{call.callee_name}/<lambda>"
|
|
1353
|
+
lambda_arg = arg1
|
|
1354
|
+
|
|
1355
|
+
# IEndpoint<T> override: the call is inside an `AddRoute` method on
|
|
1356
|
+
# a class implementing IEndpoint — attribute to {Class}.HandleAsync
|
|
1357
|
+
# (or the resolved handler-method name) rather than the lambda.
|
|
1358
|
+
iendpoint_handler = self._iendpoint_handler_for_call(call, _iendpoint_classes)
|
|
1359
|
+
if iendpoint_handler is not None:
|
|
1360
|
+
handler_name = iendpoint_handler
|
|
1361
|
+
# If the IEndpoint class exposes the resolved method, route
|
|
1362
|
+
# param extraction can use it instead of the AddRoute lambda.
|
|
1363
|
+
handler_method = (
|
|
1364
|
+
_method_lookup.get(iendpoint_handler.rsplit(".", 1)[-1]) or handler_method
|
|
1365
|
+
)
|
|
1366
|
+
|
|
1367
|
+
# ── Path parameters ──────────────────────────────────────────────
|
|
1368
|
+
path_params = self._extract_path_params_from_template(path)
|
|
1369
|
+
|
|
1370
|
+
# ── Body / query from lambda typed params or method reference ──────
|
|
1371
|
+
query_params: list[ExtractedParameter] = []
|
|
1372
|
+
header_params: list[ExtractedParameter] = []
|
|
1373
|
+
body: ExtractedBody | None = None
|
|
1374
|
+
if lambda_arg is not None and lambda_arg.lambda_parameter_types:
|
|
1375
|
+
path_tpl_names = {m.group(1) for m in re.finditer(r"\{([^}:]+)(?::[^}]+)?\}", path)}
|
|
1376
|
+
# path_params already seeded from template; don't duplicate
|
|
1377
|
+
path_param_names = {p.name for p in path_params}
|
|
1378
|
+
body, query_params = self._classify_lambda_params(
|
|
1379
|
+
lambda_arg.lambda_parameter_types,
|
|
1380
|
+
path_tpl_names,
|
|
1381
|
+
path_param_names,
|
|
1382
|
+
context,
|
|
1383
|
+
)
|
|
1384
|
+
elif handler_method is not None:
|
|
1385
|
+
path_params, query_params, header_params, _cookie_params, body = (
|
|
1386
|
+
self._extract_params(handler_method, path, context, parsed_file)
|
|
1387
|
+
)
|
|
1388
|
+
|
|
1389
|
+
# ── Auth chaining (.RequireAuthorization etc.) ────────────────────
|
|
1390
|
+
call_line = call.location.line if call.location else -1
|
|
1391
|
+
chained = auth_chainers.get(call_line, [])
|
|
1392
|
+
# Only AUTH_CHAINERS (RequireAuthorization, AllowAnonymous, RequirePermission …)
|
|
1393
|
+
# trigger an auth dep_ref. Use the chainer name so the auth-keyword matcher in
|
|
1394
|
+
# _populate_route_auth_mapping can recognize it (e.g. "permission" in "RequirePermission").
|
|
1395
|
+
auth_chainer = next((c for c in chained if c in _MINIMAL_API_AUTH_CHAINERS), None)
|
|
1396
|
+
auth_refs = [f"{auth_chainer}@{call_line}"] if auth_chainer else []
|
|
1397
|
+
# [Authorize] on the lambda handler → add an explicit auth dep_ref
|
|
1398
|
+
# that the auth-keyword matcher in _populate_route_auth_mapping picks up
|
|
1399
|
+
lambda_auth_attrs = lambda_arg.lambda_attribute_names if lambda_arg else []
|
|
1400
|
+
if "Authorize" in lambda_auth_attrs and "AllowAnonymous" not in lambda_auth_attrs:
|
|
1401
|
+
auth_refs.append(f"Authorize@{call_line}")
|
|
1402
|
+
|
|
1403
|
+
# ── Response status from chained .Produces<T>(statusCode) ─────────
|
|
1404
|
+
response_status = 200
|
|
1405
|
+
produces_calls = [
|
|
1406
|
+
c
|
|
1407
|
+
for c in parsed_file.call_sites
|
|
1408
|
+
if c.callee_name.split(".")[-1] == "Produces"
|
|
1409
|
+
and c.receiver_expression
|
|
1410
|
+
and c.receiver_expression.startswith("line:")
|
|
1411
|
+
and c.receiver_expression[5:].lstrip("-").isdigit()
|
|
1412
|
+
and int(c.receiver_expression[5:]) == call_line
|
|
1413
|
+
]
|
|
1414
|
+
for pc in produces_calls[:1]:
|
|
1415
|
+
# Produces<T>() → 200; Produces<T>(statusCode) → statusCode
|
|
1416
|
+
int_args = [a for a in pc.arguments if a.is_literal and a.literal_type == "int"]
|
|
1417
|
+
if int_args:
|
|
1418
|
+
response_status = int(int_args[0].literal_value)
|
|
1419
|
+
|
|
1420
|
+
# ── Confidence ───────────────────────────────────────────────────
|
|
1421
|
+
confidence = Confidence.HIGH if path != "/[minimal-api]" else Confidence.MEDIUM
|
|
1422
|
+
|
|
1423
|
+
routes.append(
|
|
1424
|
+
ExtractedRoute(
|
|
1425
|
+
method=hm,
|
|
1426
|
+
path=path,
|
|
1427
|
+
handler_function=_str_to_qname(handler_name),
|
|
1428
|
+
handler_location=call.location,
|
|
1429
|
+
path_params=path_params,
|
|
1430
|
+
query_params=query_params,
|
|
1431
|
+
header_params=header_params,
|
|
1432
|
+
cookie_params=[],
|
|
1433
|
+
body=body,
|
|
1434
|
+
response=ExtractedResponse(status_code=response_status),
|
|
1435
|
+
tags=["minimal-api"],
|
|
1436
|
+
dependency_refs=auth_refs,
|
|
1437
|
+
confidence=confidence,
|
|
1438
|
+
kind="http",
|
|
1439
|
+
)
|
|
1440
|
+
)
|
|
1441
|
+
|
|
1442
|
+
# Gap 1: MapMethods call sites
|
|
1443
|
+
for call in map_methods_calls:
|
|
1444
|
+
# arguments[0] = path, arguments[1] = http methods array, arguments[2] = handler
|
|
1445
|
+
if not call.arguments or not call.arguments[0].is_literal:
|
|
1446
|
+
continue
|
|
1447
|
+
path = str(call.arguments[0].literal_value or "")
|
|
1448
|
+
path = self._normalize_path(path)
|
|
1449
|
+
if path and not path.startswith("/"):
|
|
1450
|
+
path = "/" + path
|
|
1451
|
+
|
|
1452
|
+
# Parse HTTP methods from arguments[1]
|
|
1453
|
+
http_methods_list: list[HttpMethod] = []
|
|
1454
|
+
if len(call.arguments) > 1:
|
|
1455
|
+
methods_text = call.arguments[1].expression_text or ""
|
|
1456
|
+
for match in re.finditer(r'HttpMethods\.(\w+)|"(\w+)"', methods_text):
|
|
1457
|
+
raw = match.group(1) or match.group(2)
|
|
1458
|
+
with contextlib.suppress(KeyError):
|
|
1459
|
+
http_methods_list.append(HttpMethod[raw.upper()])
|
|
1460
|
+
if not http_methods_list:
|
|
1461
|
+
http_methods_list = [HttpMethod.GET]
|
|
1462
|
+
|
|
1463
|
+
# Extract handler name / method reference from arguments[2]
|
|
1464
|
+
handler_name = call.callee_name
|
|
1465
|
+
lambda_arg = None
|
|
1466
|
+
handler_method = None
|
|
1467
|
+
if len(call.arguments) > 2:
|
|
1468
|
+
arg2 = call.arguments[2]
|
|
1469
|
+
if arg2.is_variable and arg2.variable_name:
|
|
1470
|
+
handler_name = arg2.variable_name
|
|
1471
|
+
handler_method = _method_lookup.get(handler_name)
|
|
1472
|
+
elif arg2.is_expression and arg2.expression_text == "<lambda>":
|
|
1473
|
+
handler_name = f"{call.callee_name}/<lambda>"
|
|
1474
|
+
lambda_arg = arg2
|
|
1475
|
+
|
|
1476
|
+
path_params = self._extract_path_params_from_template(path)
|
|
1477
|
+
query_params = []
|
|
1478
|
+
header_params = []
|
|
1479
|
+
body = None
|
|
1480
|
+
if lambda_arg is not None and lambda_arg.lambda_parameter_types:
|
|
1481
|
+
path_tpl_names = {m.group(1) for m in re.finditer(r"\{([^}:]+)(?::[^}]+)?\}", path)}
|
|
1482
|
+
path_param_names = {p.name for p in path_params}
|
|
1483
|
+
body, query_params = self._classify_lambda_params(
|
|
1484
|
+
lambda_arg.lambda_parameter_types,
|
|
1485
|
+
path_tpl_names,
|
|
1486
|
+
path_param_names,
|
|
1487
|
+
context,
|
|
1488
|
+
)
|
|
1489
|
+
elif handler_method is not None:
|
|
1490
|
+
path_params, query_params, header_params, _cookie_params, body = (
|
|
1491
|
+
self._extract_params(handler_method, path, context, parsed_file)
|
|
1492
|
+
)
|
|
1493
|
+
|
|
1494
|
+
call_line = call.location.line if call.location else -1
|
|
1495
|
+
chained = auth_chainers.get(call_line, [])
|
|
1496
|
+
auth_chainer = next((c for c in chained if c in _MINIMAL_API_AUTH_CHAINERS), None)
|
|
1497
|
+
auth_refs = [f"{auth_chainer}@{call_line}"] if auth_chainer else []
|
|
1498
|
+
# C3: [Authorize] on the MapMethods lambda handler → explicit auth dep_ref
|
|
1499
|
+
lambda_auth_attrs = lambda_arg.lambda_attribute_names if lambda_arg else []
|
|
1500
|
+
if "Authorize" in lambda_auth_attrs and "AllowAnonymous" not in lambda_auth_attrs:
|
|
1501
|
+
auth_refs.append(f"Authorize@{call_line}")
|
|
1502
|
+
|
|
1503
|
+
for hm in http_methods_list:
|
|
1504
|
+
routes.append(
|
|
1505
|
+
ExtractedRoute(
|
|
1506
|
+
method=hm,
|
|
1507
|
+
path=path,
|
|
1508
|
+
handler_function=_str_to_qname(handler_name),
|
|
1509
|
+
handler_location=call.location,
|
|
1510
|
+
path_params=path_params,
|
|
1511
|
+
query_params=query_params,
|
|
1512
|
+
header_params=header_params,
|
|
1513
|
+
cookie_params=[],
|
|
1514
|
+
body=body,
|
|
1515
|
+
response=ExtractedResponse(status_code=200),
|
|
1516
|
+
tags=["minimal-api"],
|
|
1517
|
+
dependency_refs=auth_refs,
|
|
1518
|
+
confidence=Confidence.HIGH,
|
|
1519
|
+
kind="http",
|
|
1520
|
+
)
|
|
1521
|
+
)
|
|
1522
|
+
|
|
1523
|
+
# Gap 4: MapHealthChecks call sites
|
|
1524
|
+
for call in parsed_file.call_sites:
|
|
1525
|
+
if call.callee_name.split(".")[-1] != "MapHealthChecks":
|
|
1526
|
+
continue
|
|
1527
|
+
if not call.arguments or not call.arguments[0].is_literal:
|
|
1528
|
+
continue
|
|
1529
|
+
hc_path = str(call.arguments[0].literal_value or "")
|
|
1530
|
+
if not hc_path.startswith("/"):
|
|
1531
|
+
hc_path = "/" + hc_path
|
|
1532
|
+
routes.append(
|
|
1533
|
+
ExtractedRoute(
|
|
1534
|
+
method=HttpMethod.GET,
|
|
1535
|
+
path=hc_path,
|
|
1536
|
+
handler_function=_str_to_qname("MapHealthChecks"),
|
|
1537
|
+
handler_location=call.location,
|
|
1538
|
+
path_params=[],
|
|
1539
|
+
query_params=[],
|
|
1540
|
+
header_params=[],
|
|
1541
|
+
cookie_params=[],
|
|
1542
|
+
body=None,
|
|
1543
|
+
response=ExtractedResponse(status_code=200),
|
|
1544
|
+
tags=["health"],
|
|
1545
|
+
dependency_refs=[],
|
|
1546
|
+
confidence=Confidence.HIGH,
|
|
1547
|
+
kind="health",
|
|
1548
|
+
)
|
|
1549
|
+
)
|
|
1550
|
+
|
|
1551
|
+
return routes
|
|
1552
|
+
|
|
1553
|
+
# -------------------------------------------------------------------------
|
|
1554
|
+
# IEndpointGroup pattern (CleanArchitecture / RouteGroupBuilder style)
|
|
1555
|
+
# -------------------------------------------------------------------------
|
|
1556
|
+
|
|
1557
|
+
def _extract_endpoint_group_routes(
|
|
1558
|
+
self,
|
|
1559
|
+
parsed_file: ParsedFile,
|
|
1560
|
+
context: AnalysisContext | None = None,
|
|
1561
|
+
) -> list[ExtractedRoute]:
|
|
1562
|
+
"""Extract routes from the IEndpointGroup pattern.
|
|
1563
|
+
|
|
1564
|
+
In this pattern a class implements IEndpointGroup and registers routes
|
|
1565
|
+
inside a static ``Map(RouteGroupBuilder groupBuilder)`` method:
|
|
1566
|
+
|
|
1567
|
+
public class TodoItems : IEndpointGroup {
|
|
1568
|
+
public static void Map(RouteGroupBuilder groupBuilder) {
|
|
1569
|
+
groupBuilder.RequireAuthorization(); // ← group auth
|
|
1570
|
+
groupBuilder.MapPost(CreateTodoItem);
|
|
1571
|
+
groupBuilder.MapPut(UpdateTodoItem, "{id}");
|
|
1572
|
+
}
|
|
1573
|
+
public static async Task<...> CreateTodoItem(ISender s, CreateTodoItemCommand cmd) { ... }
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
Route prefix defaults to ``/api/{ClassName}``; group-level
|
|
1577
|
+
``RequireAuthorization()`` is inherited by all routes in the group;
|
|
1578
|
+
handler parameters are resolved from the named static methods in the
|
|
1579
|
+
same class. ``MapIdentityApi<T>()`` emits the standard Identity
|
|
1580
|
+
endpoint set.
|
|
1581
|
+
"""
|
|
1582
|
+
routes: list[ExtractedRoute] = []
|
|
1583
|
+
|
|
1584
|
+
for cls in parsed_file.classes:
|
|
1585
|
+
if not any(b.rsplit(".", 1)[-1] in _ENDPOINT_GROUP_BASES for b in cls.base_classes):
|
|
1586
|
+
continue
|
|
1587
|
+
|
|
1588
|
+
# Route prefix: check for an explicit Prefix field/property, else /api/{ClassName}
|
|
1589
|
+
prefix_field = next(
|
|
1590
|
+
(f for f in cls.fields if "prefix" in f.name.lower() and f.default_value),
|
|
1591
|
+
None,
|
|
1592
|
+
)
|
|
1593
|
+
if prefix_field:
|
|
1594
|
+
raw_prefix = prefix_field.default_value or ""
|
|
1595
|
+
if (
|
|
1596
|
+
len(raw_prefix) >= 2
|
|
1597
|
+
and raw_prefix[0] == raw_prefix[-1]
|
|
1598
|
+
and raw_prefix[0] in ('"', "'")
|
|
1599
|
+
):
|
|
1600
|
+
raw_prefix = raw_prefix[1:-1]
|
|
1601
|
+
group_prefix = raw_prefix if raw_prefix.startswith("/") else "/" + raw_prefix
|
|
1602
|
+
else:
|
|
1603
|
+
group_prefix = f"/api/{cls.name}"
|
|
1604
|
+
|
|
1605
|
+
# Handler method lookup: name → ParsedFunction
|
|
1606
|
+
method_map: dict[str, ParsedFunction] = {m.name: m for m in cls.methods}
|
|
1607
|
+
|
|
1608
|
+
# Scan all call sites for group-builder calls and chainers
|
|
1609
|
+
map_calls = []
|
|
1610
|
+
auth_chainers: dict[int, list[str]] = {}
|
|
1611
|
+
has_identity_api = False
|
|
1612
|
+
|
|
1613
|
+
for call in parsed_file.call_sites:
|
|
1614
|
+
method_name = call.callee_name.split(".")[-1]
|
|
1615
|
+
recv = call.receiver_expression or ""
|
|
1616
|
+
|
|
1617
|
+
if method_name == "MapIdentityApi" and recv == "groupBuilder":
|
|
1618
|
+
has_identity_api = True
|
|
1619
|
+
elif method_name in _MINIMAL_API_METHODS and recv == "groupBuilder":
|
|
1620
|
+
map_calls.append(call)
|
|
1621
|
+
elif method_name in _MINIMAL_API_CHAINERS and recv.startswith("line:"):
|
|
1622
|
+
with contextlib.suppress(ValueError):
|
|
1623
|
+
auth_chainers.setdefault(int(recv[5:]), []).append(method_name)
|
|
1624
|
+
|
|
1625
|
+
# Group-level auth: RequireAuthorization() called directly on groupBuilder
|
|
1626
|
+
group_requires_auth = any(
|
|
1627
|
+
call.callee_name.split(".")[-1] == "RequireAuthorization"
|
|
1628
|
+
and (call.receiver_expression or "") == "groupBuilder"
|
|
1629
|
+
for call in parsed_file.call_sites
|
|
1630
|
+
)
|
|
1631
|
+
|
|
1632
|
+
if has_identity_api:
|
|
1633
|
+
routes.extend(self._identity_api_routes(group_prefix))
|
|
1634
|
+
|
|
1635
|
+
for call in map_calls:
|
|
1636
|
+
method_name = call.callee_name.split(".")[-1]
|
|
1637
|
+
hm = _MINIMAL_API_METHODS[method_name]
|
|
1638
|
+
call_line = call.location.line if call.location else -1
|
|
1639
|
+
|
|
1640
|
+
# Extract route suffix (first string literal arg) and handler name
|
|
1641
|
+
route_suffix = ""
|
|
1642
|
+
handler_name: str | None = None
|
|
1643
|
+
for arg in call.arguments:
|
|
1644
|
+
if arg.is_literal and arg.literal_type == "str" and arg.literal_value:
|
|
1645
|
+
if not route_suffix:
|
|
1646
|
+
route_suffix = arg.literal_value.strip("/")
|
|
1647
|
+
elif arg.is_variable and arg.variable_name and handler_name is None:
|
|
1648
|
+
handler_name = arg.variable_name
|
|
1649
|
+
|
|
1650
|
+
path = f"{group_prefix}/{route_suffix}" if route_suffix else group_prefix
|
|
1651
|
+
path = self._normalize_path(path)
|
|
1652
|
+
|
|
1653
|
+
path_params = self._extract_path_params_from_template(path)
|
|
1654
|
+
|
|
1655
|
+
# Extract body/query from handler method signature
|
|
1656
|
+
body: ExtractedBody | None = None
|
|
1657
|
+
query_params: list[ExtractedParameter] = []
|
|
1658
|
+
if handler_name and handler_name in method_map:
|
|
1659
|
+
handler_method = method_map[handler_name]
|
|
1660
|
+
lambda_params = [
|
|
1661
|
+
(p.type_annotation or "object", p.name)
|
|
1662
|
+
for p in handler_method.parameters
|
|
1663
|
+
if p.type_annotation
|
|
1664
|
+
]
|
|
1665
|
+
if lambda_params:
|
|
1666
|
+
path_tpl_names = {
|
|
1667
|
+
m.group(1) for m in re.finditer(r"\{([^}:]+)(?::[^}]+)?\}", path)
|
|
1668
|
+
}
|
|
1669
|
+
path_param_names = {p.name for p in path_params}
|
|
1670
|
+
body, query_params = self._classify_lambda_params(
|
|
1671
|
+
lambda_params,
|
|
1672
|
+
path_tpl_names,
|
|
1673
|
+
path_param_names,
|
|
1674
|
+
context,
|
|
1675
|
+
)
|
|
1676
|
+
|
|
1677
|
+
# Auth: group-level OR per-route chaining
|
|
1678
|
+
chained = auth_chainers.get(call_line, [])
|
|
1679
|
+
has_route_auth = any(c in _MINIMAL_API_AUTH_CHAINERS for c in chained)
|
|
1680
|
+
auth_refs: list[str] = []
|
|
1681
|
+
if group_requires_auth or has_route_auth:
|
|
1682
|
+
auth_refs = [f"Authorize@{call_line}"]
|
|
1683
|
+
|
|
1684
|
+
routes.append(
|
|
1685
|
+
ExtractedRoute(
|
|
1686
|
+
method=hm,
|
|
1687
|
+
path=path,
|
|
1688
|
+
handler_function=_str_to_qname(handler_name or method_name),
|
|
1689
|
+
handler_location=call.location,
|
|
1690
|
+
path_params=path_params,
|
|
1691
|
+
query_params=query_params,
|
|
1692
|
+
header_params=[],
|
|
1693
|
+
cookie_params=[],
|
|
1694
|
+
body=body,
|
|
1695
|
+
response=ExtractedResponse(status_code=200),
|
|
1696
|
+
tags=["endpoint-group"],
|
|
1697
|
+
dependency_refs=auth_refs,
|
|
1698
|
+
confidence=Confidence.HIGH,
|
|
1699
|
+
kind="http",
|
|
1700
|
+
)
|
|
1701
|
+
)
|
|
1702
|
+
|
|
1703
|
+
return routes
|
|
1704
|
+
|
|
1705
|
+
def _identity_api_routes(self, group_prefix: str) -> list[ExtractedRoute]:
|
|
1706
|
+
"""Synthesise the well-known ASP.NET Core Identity API endpoints."""
|
|
1707
|
+
routes = []
|
|
1708
|
+
stub_location = CodeLocation(file="[identity-api]", line=0)
|
|
1709
|
+
for method, suffix, requires_auth in _IDENTITY_API_ROUTES:
|
|
1710
|
+
path = f"{group_prefix}/{suffix}"
|
|
1711
|
+
auth_refs = ["Authorize@identity"] if requires_auth else []
|
|
1712
|
+
routes.append(
|
|
1713
|
+
ExtractedRoute(
|
|
1714
|
+
method=method,
|
|
1715
|
+
path=path,
|
|
1716
|
+
handler_function=_str_to_qname(f"IdentityApi.{suffix}"),
|
|
1717
|
+
handler_location=stub_location,
|
|
1718
|
+
path_params=[],
|
|
1719
|
+
query_params=[],
|
|
1720
|
+
header_params=[],
|
|
1721
|
+
cookie_params=[],
|
|
1722
|
+
body=None,
|
|
1723
|
+
response=ExtractedResponse(status_code=200),
|
|
1724
|
+
tags=["identity-api"],
|
|
1725
|
+
dependency_refs=auth_refs,
|
|
1726
|
+
confidence=Confidence.HIGH,
|
|
1727
|
+
kind="http",
|
|
1728
|
+
)
|
|
1729
|
+
)
|
|
1730
|
+
return routes
|
|
1731
|
+
|
|
1732
|
+
def _extract_path_params_from_template(self, path: str) -> list[ExtractedParameter]:
|
|
1733
|
+
"""
|
|
1734
|
+
Extract ``{name}`` placeholders from a (already-normalized) path template
|
|
1735
|
+
as PATH parameters.
|
|
1736
|
+
|
|
1737
|
+
After ``_normalize_path`` all constraints have been stripped, so we only
|
|
1738
|
+
need to match ``{name}`` forms.
|
|
1739
|
+
"""
|
|
1740
|
+
params = []
|
|
1741
|
+
for name in re.findall(r"\{([^}]+)\}", path):
|
|
1742
|
+
params.append(
|
|
1743
|
+
ExtractedParameter(
|
|
1744
|
+
name=name,
|
|
1745
|
+
location=ParameterLocation.PATH,
|
|
1746
|
+
required=True,
|
|
1747
|
+
)
|
|
1748
|
+
)
|
|
1749
|
+
return params
|
|
1750
|
+
|
|
1751
|
+
# -------------------------------------------------------------------------
|
|
1752
|
+
# Parameter extraction
|
|
1753
|
+
# -------------------------------------------------------------------------
|
|
1754
|
+
|
|
1755
|
+
def _extract_params(
|
|
1756
|
+
self,
|
|
1757
|
+
method: ParsedFunction,
|
|
1758
|
+
path: str,
|
|
1759
|
+
context: AnalysisContext | None,
|
|
1760
|
+
parsed_file: ParsedFile,
|
|
1761
|
+
) -> tuple[
|
|
1762
|
+
list[ExtractedParameter],
|
|
1763
|
+
list[ExtractedParameter],
|
|
1764
|
+
list[ExtractedParameter],
|
|
1765
|
+
list[ExtractedParameter],
|
|
1766
|
+
ExtractedBody | None,
|
|
1767
|
+
]:
|
|
1768
|
+
path_params: list[ExtractedParameter] = []
|
|
1769
|
+
query_params: list[ExtractedParameter] = []
|
|
1770
|
+
header_params: list[ExtractedParameter] = []
|
|
1771
|
+
cookie_params: list[ExtractedParameter] = []
|
|
1772
|
+
body: ExtractedBody | None = None
|
|
1773
|
+
|
|
1774
|
+
path_tpl_names = set(re.findall(r"\{([^}:]+)(?::[^}]+)?\}", path))
|
|
1775
|
+
|
|
1776
|
+
for param in method.parameters:
|
|
1777
|
+
meta = param.metadata or {}
|
|
1778
|
+
constraints = self._extract_constraints(meta)
|
|
1779
|
+
|
|
1780
|
+
# [AsParameters] — properties bound individually from query/route/path.
|
|
1781
|
+
# Service bundles (Manager, Service, Context, etc.) are DI injections — skip them.
|
|
1782
|
+
# Other types are value-object param groups — treat as aggregate query param.
|
|
1783
|
+
if "AsParameters" in meta:
|
|
1784
|
+
if param.type_annotation:
|
|
1785
|
+
_svc_sfx = (
|
|
1786
|
+
"Services",
|
|
1787
|
+
"Manager",
|
|
1788
|
+
"Service",
|
|
1789
|
+
"Repository",
|
|
1790
|
+
"Context",
|
|
1791
|
+
"DbContext",
|
|
1792
|
+
"Factory",
|
|
1793
|
+
"Provider",
|
|
1794
|
+
"Sender",
|
|
1795
|
+
"Mediator",
|
|
1796
|
+
"Dispatcher",
|
|
1797
|
+
"Processor",
|
|
1798
|
+
"Client",
|
|
1799
|
+
"Queries",
|
|
1800
|
+
)
|
|
1801
|
+
base_type = param.type_annotation.split("<")[0].strip()
|
|
1802
|
+
if any(base_type.endswith(s) for s in _svc_sfx):
|
|
1803
|
+
continue # DI service bundle
|
|
1804
|
+
# Non-service [AsParameters]: emit as an aggregate query param
|
|
1805
|
+
query_params.append(
|
|
1806
|
+
ExtractedParameter(
|
|
1807
|
+
name=param.name,
|
|
1808
|
+
location=ParameterLocation.QUERY,
|
|
1809
|
+
type_annotation=param.type_annotation,
|
|
1810
|
+
required=False,
|
|
1811
|
+
code_location=param.location,
|
|
1812
|
+
)
|
|
1813
|
+
)
|
|
1814
|
+
continue
|
|
1815
|
+
|
|
1816
|
+
# Explicit [FromBody]
|
|
1817
|
+
if "FromBody" in meta:
|
|
1818
|
+
body = ExtractedBody(
|
|
1819
|
+
content_type="application/json",
|
|
1820
|
+
model_name=param.type_annotation,
|
|
1821
|
+
required=True,
|
|
1822
|
+
)
|
|
1823
|
+
continue
|
|
1824
|
+
|
|
1825
|
+
# Explicit [FromRoute]
|
|
1826
|
+
if "FromRoute" in meta:
|
|
1827
|
+
alias = meta.get("FromRoute")
|
|
1828
|
+
name = alias if isinstance(alias, str) and alias != "True" else param.name
|
|
1829
|
+
path_params.append(
|
|
1830
|
+
ExtractedParameter(
|
|
1831
|
+
name=name,
|
|
1832
|
+
location=ParameterLocation.PATH,
|
|
1833
|
+
type_annotation=param.type_annotation,
|
|
1834
|
+
required=True,
|
|
1835
|
+
constraints=constraints,
|
|
1836
|
+
code_location=param.location,
|
|
1837
|
+
)
|
|
1838
|
+
)
|
|
1839
|
+
continue
|
|
1840
|
+
|
|
1841
|
+
# Explicit [FromQuery]
|
|
1842
|
+
if "FromQuery" in meta:
|
|
1843
|
+
alias = meta.get("FromQuery")
|
|
1844
|
+
name = alias if isinstance(alias, str) and alias != "True" else param.name
|
|
1845
|
+
req = "Required" in meta
|
|
1846
|
+
query_params.append(
|
|
1847
|
+
ExtractedParameter(
|
|
1848
|
+
name=name,
|
|
1849
|
+
location=ParameterLocation.QUERY,
|
|
1850
|
+
type_annotation=param.type_annotation,
|
|
1851
|
+
required=req,
|
|
1852
|
+
default_value=param.default_value,
|
|
1853
|
+
constraints=constraints,
|
|
1854
|
+
code_location=param.location,
|
|
1855
|
+
)
|
|
1856
|
+
)
|
|
1857
|
+
continue
|
|
1858
|
+
|
|
1859
|
+
# Explicit [FromHeader]
|
|
1860
|
+
if "FromHeader" in meta:
|
|
1861
|
+
alias = meta.get("FromHeader")
|
|
1862
|
+
name = alias if isinstance(alias, str) and alias != "True" else param.name
|
|
1863
|
+
header_params.append(
|
|
1864
|
+
ExtractedParameter(
|
|
1865
|
+
name=name,
|
|
1866
|
+
location=ParameterLocation.HEADER,
|
|
1867
|
+
type_annotation=param.type_annotation,
|
|
1868
|
+
required=True,
|
|
1869
|
+
constraints=constraints,
|
|
1870
|
+
code_location=param.location,
|
|
1871
|
+
)
|
|
1872
|
+
)
|
|
1873
|
+
continue
|
|
1874
|
+
|
|
1875
|
+
# Explicit [FromForm]
|
|
1876
|
+
if "FromForm" in meta:
|
|
1877
|
+
if body is None:
|
|
1878
|
+
body = ExtractedBody(
|
|
1879
|
+
content_type="multipart/form-data",
|
|
1880
|
+
model_name=param.type_annotation,
|
|
1881
|
+
required=True,
|
|
1882
|
+
)
|
|
1883
|
+
continue
|
|
1884
|
+
|
|
1885
|
+
# No explicit annotation — infer from path template
|
|
1886
|
+
if param.name in path_tpl_names:
|
|
1887
|
+
path_params.append(
|
|
1888
|
+
ExtractedParameter(
|
|
1889
|
+
name=param.name,
|
|
1890
|
+
location=ParameterLocation.PATH,
|
|
1891
|
+
type_annotation=param.type_annotation,
|
|
1892
|
+
required=True,
|
|
1893
|
+
constraints=constraints,
|
|
1894
|
+
code_location=param.location,
|
|
1895
|
+
)
|
|
1896
|
+
)
|
|
1897
|
+
continue
|
|
1898
|
+
|
|
1899
|
+
# Skip framework-injected types
|
|
1900
|
+
skip_types = {
|
|
1901
|
+
"HttpContext",
|
|
1902
|
+
"HttpRequest",
|
|
1903
|
+
"HttpResponse",
|
|
1904
|
+
"CancellationToken",
|
|
1905
|
+
"ILogger",
|
|
1906
|
+
"ClaimsPrincipal",
|
|
1907
|
+
"IFormFile",
|
|
1908
|
+
"IFormFileCollection",
|
|
1909
|
+
}
|
|
1910
|
+
if param.type_annotation in skip_types:
|
|
1911
|
+
continue
|
|
1912
|
+
# Also skip types that start with "I" (interfaces injected via DI)
|
|
1913
|
+
if (
|
|
1914
|
+
param.type_annotation
|
|
1915
|
+
and param.type_annotation.startswith("I")
|
|
1916
|
+
and len(param.type_annotation) > 1
|
|
1917
|
+
and param.type_annotation[1].isupper()
|
|
1918
|
+
):
|
|
1919
|
+
continue
|
|
1920
|
+
|
|
1921
|
+
# Complex type with no annotation → treat as [FromBody] (ASP.NET default)
|
|
1922
|
+
if param.type_annotation and self._is_complex_type(param.type_annotation):
|
|
1923
|
+
if body is None:
|
|
1924
|
+
body = ExtractedBody(
|
|
1925
|
+
content_type="application/json",
|
|
1926
|
+
model_name=param.type_annotation,
|
|
1927
|
+
required=True,
|
|
1928
|
+
)
|
|
1929
|
+
continue
|
|
1930
|
+
|
|
1931
|
+
# Scalar with no annotation → query param (ASP.NET Core default for scalars)
|
|
1932
|
+
query_params.append(
|
|
1933
|
+
ExtractedParameter(
|
|
1934
|
+
name=param.name,
|
|
1935
|
+
location=ParameterLocation.QUERY,
|
|
1936
|
+
type_annotation=param.type_annotation,
|
|
1937
|
+
required=param.default_value is None and param.default_value != "null",
|
|
1938
|
+
default_value=param.default_value,
|
|
1939
|
+
constraints=constraints,
|
|
1940
|
+
code_location=param.location,
|
|
1941
|
+
)
|
|
1942
|
+
)
|
|
1943
|
+
|
|
1944
|
+
return path_params, query_params, header_params, cookie_params, body
|
|
1945
|
+
|
|
1946
|
+
def _extract_constraints(self, meta: dict[str, Any]) -> dict[str, Any]:
|
|
1947
|
+
constraints: dict[str, Any] = {}
|
|
1948
|
+
if "Required" in meta:
|
|
1949
|
+
constraints["not_null"] = True
|
|
1950
|
+
if "Range" in meta:
|
|
1951
|
+
val = meta["Range"]
|
|
1952
|
+
if isinstance(val, dict):
|
|
1953
|
+
if "Minimum" in val:
|
|
1954
|
+
constraints["min"] = val["Minimum"]
|
|
1955
|
+
if "Maximum" in val:
|
|
1956
|
+
constraints["max"] = val["Maximum"]
|
|
1957
|
+
for attr in ("MinLength", "StringLength"):
|
|
1958
|
+
if attr in meta:
|
|
1959
|
+
val = meta[attr]
|
|
1960
|
+
n = self._to_number(val)
|
|
1961
|
+
if n is not None:
|
|
1962
|
+
constraints.setdefault("min_length", n)
|
|
1963
|
+
if "MaxLength" in meta:
|
|
1964
|
+
val = meta["MaxLength"]
|
|
1965
|
+
n = self._to_number(val)
|
|
1966
|
+
if n is not None:
|
|
1967
|
+
constraints["max_length"] = n
|
|
1968
|
+
if "RegularExpression" in meta:
|
|
1969
|
+
val = meta["RegularExpression"]
|
|
1970
|
+
constraints["pattern"] = val if isinstance(val, str) else str(val)
|
|
1971
|
+
if "EmailAddress" in meta:
|
|
1972
|
+
constraints["format"] = "email"
|
|
1973
|
+
if "Url" in meta:
|
|
1974
|
+
constraints["format"] = "url"
|
|
1975
|
+
return constraints
|
|
1976
|
+
|
|
1977
|
+
@staticmethod
|
|
1978
|
+
def _to_number(val: Any) -> int | float | None:
|
|
1979
|
+
if isinstance(val, (int, float)) and not isinstance(val, bool):
|
|
1980
|
+
return val
|
|
1981
|
+
if isinstance(val, str):
|
|
1982
|
+
try:
|
|
1983
|
+
return int(val)
|
|
1984
|
+
except ValueError:
|
|
1985
|
+
pass
|
|
1986
|
+
try:
|
|
1987
|
+
return float(val)
|
|
1988
|
+
except ValueError:
|
|
1989
|
+
pass
|
|
1990
|
+
return None
|
|
1991
|
+
|
|
1992
|
+
def _classify_lambda_params(
|
|
1993
|
+
self,
|
|
1994
|
+
lambda_params: list[tuple[str, str]],
|
|
1995
|
+
path_tpl_names: set[str],
|
|
1996
|
+
path_param_names: set[str],
|
|
1997
|
+
context: AnalysisContext | None,
|
|
1998
|
+
) -> tuple[ExtractedBody | None, list[ExtractedParameter]]:
|
|
1999
|
+
"""Classify typed lambda parameters into body / query params.
|
|
2000
|
+
|
|
2001
|
+
ASP.NET Core Minimal API binding rules (simplified):
|
|
2002
|
+
- Name matches a path template placeholder → already a path param (skip)
|
|
2003
|
+
- DI interface (starts with ``I`` + uppercase) → skip (framework-injected)
|
|
2004
|
+
- Framework special types (HttpContext, CancellationToken, …) → skip
|
|
2005
|
+
- Complex (non-scalar) type → request body (first one wins)
|
|
2006
|
+
- Scalar type → query param
|
|
2007
|
+
"""
|
|
2008
|
+
_DI_SKIP: frozenset[str] = frozenset(
|
|
2009
|
+
{
|
|
2010
|
+
"HttpContext",
|
|
2011
|
+
"HttpRequest",
|
|
2012
|
+
"HttpResponse",
|
|
2013
|
+
"CancellationToken",
|
|
2014
|
+
"IFormFile",
|
|
2015
|
+
"IFormFileCollection",
|
|
2016
|
+
"ClaimsPrincipal",
|
|
2017
|
+
"ILogger",
|
|
2018
|
+
"LinkGenerator",
|
|
2019
|
+
# Common ASP.NET Core service types injected directly (not via interface)
|
|
2020
|
+
"SignInManager",
|
|
2021
|
+
"UserManager",
|
|
2022
|
+
"RoleManager",
|
|
2023
|
+
"ISender",
|
|
2024
|
+
"IMediator",
|
|
2025
|
+
}
|
|
2026
|
+
)
|
|
2027
|
+
|
|
2028
|
+
# Name suffixes that strongly indicate a DI service, not a data model
|
|
2029
|
+
_SERVICE_SUFFIXES: tuple[str, ...] = (
|
|
2030
|
+
"Manager",
|
|
2031
|
+
"Service",
|
|
2032
|
+
"Repository",
|
|
2033
|
+
"Context",
|
|
2034
|
+
"DbContext",
|
|
2035
|
+
"Factory",
|
|
2036
|
+
"Provider",
|
|
2037
|
+
"Sender",
|
|
2038
|
+
"Mediator",
|
|
2039
|
+
"Dispatcher",
|
|
2040
|
+
"Handler",
|
|
2041
|
+
"Processor",
|
|
2042
|
+
"Client",
|
|
2043
|
+
)
|
|
2044
|
+
|
|
2045
|
+
body: ExtractedBody | None = None
|
|
2046
|
+
query_params: list[ExtractedParameter] = []
|
|
2047
|
+
|
|
2048
|
+
for type_ann, param_name in lambda_params:
|
|
2049
|
+
# Skip path params already extracted from the template
|
|
2050
|
+
if param_name in path_tpl_names or param_name in path_param_names:
|
|
2051
|
+
continue
|
|
2052
|
+
|
|
2053
|
+
# Strip generics for interface check
|
|
2054
|
+
raw_type = type_ann.split("<")[0].strip()
|
|
2055
|
+
|
|
2056
|
+
# DI-injected interfaces (IRepository<T>, ILogger, etc.) and known services
|
|
2057
|
+
if raw_type in _DI_SKIP:
|
|
2058
|
+
continue
|
|
2059
|
+
if len(raw_type) > 1 and raw_type[0] == "I" and raw_type[1].isupper():
|
|
2060
|
+
continue
|
|
2061
|
+
if any(raw_type.endswith(sfx) for sfx in _SERVICE_SUFFIXES):
|
|
2062
|
+
continue
|
|
2063
|
+
|
|
2064
|
+
# Check type resolver first (best signal)
|
|
2065
|
+
resolved = None
|
|
2066
|
+
if context and context.type_resolver:
|
|
2067
|
+
resolved = context.type_resolver.resolve_type(type_ann)
|
|
2068
|
+
|
|
2069
|
+
# If the type resolves but is NOT a model it's a service/entity class → skip
|
|
2070
|
+
if resolved is not None and not resolved.is_model:
|
|
2071
|
+
continue
|
|
2072
|
+
|
|
2073
|
+
if resolved is not None and resolved.is_model:
|
|
2074
|
+
# Explicit model → body
|
|
2075
|
+
if body is None:
|
|
2076
|
+
fields = (
|
|
2077
|
+
context.type_resolver.get_model_fields(resolved.qualified_name)
|
|
2078
|
+
if context and context.type_resolver
|
|
2079
|
+
else []
|
|
2080
|
+
) # type: ignore[union-attr]
|
|
2081
|
+
body = ExtractedBody(
|
|
2082
|
+
content_type="application/json",
|
|
2083
|
+
model_name=type_ann,
|
|
2084
|
+
model_fields=[f.name for f in fields] if fields else [],
|
|
2085
|
+
required=True,
|
|
2086
|
+
)
|
|
2087
|
+
elif self._is_complex_type(raw_type):
|
|
2088
|
+
# Unknown complex type — treat as body (ASP.NET default)
|
|
2089
|
+
if body is None:
|
|
2090
|
+
body = ExtractedBody(
|
|
2091
|
+
content_type="application/json",
|
|
2092
|
+
model_name=type_ann,
|
|
2093
|
+
required=True,
|
|
2094
|
+
)
|
|
2095
|
+
else:
|
|
2096
|
+
# Scalar → query param
|
|
2097
|
+
query_params.append(
|
|
2098
|
+
ExtractedParameter(
|
|
2099
|
+
name=param_name,
|
|
2100
|
+
location=ParameterLocation.QUERY,
|
|
2101
|
+
type_annotation=type_ann,
|
|
2102
|
+
required=True,
|
|
2103
|
+
)
|
|
2104
|
+
)
|
|
2105
|
+
|
|
2106
|
+
return body, query_params
|
|
2107
|
+
|
|
2108
|
+
def _is_complex_type(self, type_name: str) -> bool:
|
|
2109
|
+
# Strip nullable marker and generics before checking — handles string?, Guid?, etc.
|
|
2110
|
+
base = type_name.rstrip("?").split("<")[0].strip()
|
|
2111
|
+
simple = {
|
|
2112
|
+
"string",
|
|
2113
|
+
"int",
|
|
2114
|
+
"long",
|
|
2115
|
+
"double",
|
|
2116
|
+
"float",
|
|
2117
|
+
"decimal",
|
|
2118
|
+
"bool",
|
|
2119
|
+
"char",
|
|
2120
|
+
"byte",
|
|
2121
|
+
"short",
|
|
2122
|
+
"uint",
|
|
2123
|
+
"ulong",
|
|
2124
|
+
"Guid",
|
|
2125
|
+
"DateTime",
|
|
2126
|
+
"DateTimeOffset",
|
|
2127
|
+
"TimeSpan",
|
|
2128
|
+
"DateOnly",
|
|
2129
|
+
"TimeOnly",
|
|
2130
|
+
"object",
|
|
2131
|
+
}
|
|
2132
|
+
return base not in simple
|
|
2133
|
+
|
|
2134
|
+
# -------------------------------------------------------------------------
|
|
2135
|
+
# Auth
|
|
2136
|
+
# -------------------------------------------------------------------------
|
|
2137
|
+
|
|
2138
|
+
def _method_auth_refs(self, cls: ParsedClass, method: ParsedFunction) -> list[str]:
|
|
2139
|
+
class_authorize = any(d.name == "Authorize" for d in cls.decorators)
|
|
2140
|
+
method_authorize = any(d.name == "Authorize" for d in method.decorators)
|
|
2141
|
+
method_allow_anon = any(d.name == "AllowAnonymous" for d in method.decorators)
|
|
2142
|
+
|
|
2143
|
+
# [AllowAnonymous] on the method always wins over class-level [Authorize]
|
|
2144
|
+
if method_allow_anon:
|
|
2145
|
+
return []
|
|
2146
|
+
# Method-level [Authorize]: ref is qualified so it matches the method-level auth dep
|
|
2147
|
+
if method_authorize:
|
|
2148
|
+
return [f"{cls.name}.{method.name}"]
|
|
2149
|
+
# Class-level [Authorize]: ref is the class name — matches the class-level auth dep
|
|
2150
|
+
if class_authorize:
|
|
2151
|
+
return [cls.name]
|
|
2152
|
+
# Other security attributes (RequireScope, etc.)
|
|
2153
|
+
for dec in method.decorators:
|
|
2154
|
+
if dec.name in _SECURITY_ATTRS:
|
|
2155
|
+
return [f"{cls.name}.{method.name}"]
|
|
2156
|
+
return []
|
|
2157
|
+
|
|
2158
|
+
def extract_auth_schemes(self, parsed_file: ParsedFile) -> list[ExtractedAuthScheme]:
|
|
2159
|
+
schemes: list[ExtractedAuthScheme] = []
|
|
2160
|
+
|
|
2161
|
+
for cls in parsed_file.classes:
|
|
2162
|
+
cls_lower = cls.name.lower()
|
|
2163
|
+
|
|
2164
|
+
# Startup / Program / WebApplicationBuilder auth registration
|
|
2165
|
+
if any(
|
|
2166
|
+
kw in cls_lower for kw in ("startup", "program", "serviceextension", "authconfig")
|
|
2167
|
+
):
|
|
2168
|
+
for method in cls.methods:
|
|
2169
|
+
scheme = self._detect_auth_registration(method, cls)
|
|
2170
|
+
if scheme:
|
|
2171
|
+
schemes.append(scheme)
|
|
2172
|
+
|
|
2173
|
+
# Also scan top-level call sites (Program.cs minimal hosting).
|
|
2174
|
+
# Match on the *final* segment of the dotted callee name and require
|
|
2175
|
+
# exact equality so custom extension methods (`AddCookieSettings`)
|
|
2176
|
+
# don't masquerade as built-in registrations (`AddCookie`).
|
|
2177
|
+
for call in parsed_file.call_sites:
|
|
2178
|
+
method_segment = call.callee_name.rsplit(".", 1)[-1].lower()
|
|
2179
|
+
for pattern, scheme_type, name, confidence in _AUTH_SCHEME_CALL_PATTERNS:
|
|
2180
|
+
if method_segment == pattern:
|
|
2181
|
+
schemes.append(
|
|
2182
|
+
ExtractedAuthScheme(
|
|
2183
|
+
scheme_type=scheme_type,
|
|
2184
|
+
name=name,
|
|
2185
|
+
location=call.location,
|
|
2186
|
+
config={"detected_via": call.callee_name},
|
|
2187
|
+
confidence=confidence,
|
|
2188
|
+
)
|
|
2189
|
+
)
|
|
2190
|
+
break
|
|
2191
|
+
|
|
2192
|
+
return schemes
|
|
2193
|
+
|
|
2194
|
+
def _detect_auth_registration(
|
|
2195
|
+
self, method: ParsedFunction, cls: ParsedClass
|
|
2196
|
+
) -> ExtractedAuthScheme | None:
|
|
2197
|
+
"""Scan a method's name for auth registration patterns."""
|
|
2198
|
+
name_lower = method.name.lower()
|
|
2199
|
+
if "addauthentication" in name_lower or "configureauth" in name_lower:
|
|
2200
|
+
return ExtractedAuthScheme(
|
|
2201
|
+
scheme_type=AuthSchemeType.CUSTOM,
|
|
2202
|
+
name=method.name,
|
|
2203
|
+
location=method.location,
|
|
2204
|
+
config={"class": cls.name, "method": method.name, "framework": "aspnetcore"},
|
|
2205
|
+
confidence=Confidence.MEDIUM,
|
|
2206
|
+
)
|
|
2207
|
+
return None
|
|
2208
|
+
|
|
2209
|
+
def extract_auth_dependencies(
|
|
2210
|
+
self,
|
|
2211
|
+
parsed_file: ParsedFile,
|
|
2212
|
+
known_scheme_names: set[str] | None = None,
|
|
2213
|
+
**kwargs: Any,
|
|
2214
|
+
) -> list[ExtractedAuthDependency]:
|
|
2215
|
+
auth_deps: list[ExtractedAuthDependency] = []
|
|
2216
|
+
|
|
2217
|
+
for cls in parsed_file.classes:
|
|
2218
|
+
# Class-level [Authorize] / [AllowAnonymous]
|
|
2219
|
+
for dec in cls.decorators:
|
|
2220
|
+
if dec.name == "Authorize":
|
|
2221
|
+
roles = self._extract_roles(dec)
|
|
2222
|
+
policy = dec.arguments.get("Policy") if dec.arguments else None
|
|
2223
|
+
if policy and isinstance(policy, str) and policy not in roles:
|
|
2224
|
+
roles = roles + [policy]
|
|
2225
|
+
auth_deps.append(
|
|
2226
|
+
ExtractedAuthDependency(
|
|
2227
|
+
name=cls.name,
|
|
2228
|
+
qualified_name=cls.qualified_name,
|
|
2229
|
+
location=cls.location,
|
|
2230
|
+
dependency_type=AuthDependencyType.ANNOTATION,
|
|
2231
|
+
requires_roles=roles,
|
|
2232
|
+
confidence=Confidence.HIGH,
|
|
2233
|
+
)
|
|
2234
|
+
)
|
|
2235
|
+
|
|
2236
|
+
# Method-level security annotations
|
|
2237
|
+
for method in cls.methods:
|
|
2238
|
+
roles: list[str] = []
|
|
2239
|
+
has_security = False
|
|
2240
|
+
for dec in method.decorators:
|
|
2241
|
+
if dec.name == "Authorize":
|
|
2242
|
+
has_security = True
|
|
2243
|
+
roles.extend(self._extract_roles(dec))
|
|
2244
|
+
elif dec.name == "AllowAnonymous":
|
|
2245
|
+
has_security = True
|
|
2246
|
+
if has_security:
|
|
2247
|
+
auth_deps.append(
|
|
2248
|
+
ExtractedAuthDependency(
|
|
2249
|
+
name=f"{cls.name}.{method.name}",
|
|
2250
|
+
qualified_name=method.qualified_name,
|
|
2251
|
+
location=method.location,
|
|
2252
|
+
dependency_type=AuthDependencyType.ANNOTATION,
|
|
2253
|
+
requires_roles=roles,
|
|
2254
|
+
confidence=Confidence.HIGH,
|
|
2255
|
+
)
|
|
2256
|
+
)
|
|
2257
|
+
|
|
2258
|
+
return auth_deps
|
|
2259
|
+
|
|
2260
|
+
def _extract_roles(self, dec: ParsedDecorator) -> list[str]:
|
|
2261
|
+
roles_val = dec.arguments.get("Roles", dec.arguments.get("roles", ""))
|
|
2262
|
+
if not roles_val:
|
|
2263
|
+
return []
|
|
2264
|
+
if isinstance(roles_val, str):
|
|
2265
|
+
return [r.strip() for r in roles_val.split(",") if r.strip()]
|
|
2266
|
+
return []
|
|
2267
|
+
|
|
2268
|
+
# -------------------------------------------------------------------------
|
|
2269
|
+
# Dependencies
|
|
2270
|
+
# -------------------------------------------------------------------------
|
|
2271
|
+
|
|
2272
|
+
def extract_dependencies(self, parsed_file: ParsedFile) -> list[ExtractedDependency]:
|
|
2273
|
+
deps: list[ExtractedDependency] = []
|
|
2274
|
+
for cls in parsed_file.classes:
|
|
2275
|
+
ann_names = {d.name for d in cls.decorators}
|
|
2276
|
+
if ann_names & {"Controller", "ApiController", "Service", "Repository", "Component"}:
|
|
2277
|
+
is_auth = any(kw in cls.name.lower() for kw in _AUTH_KEYWORDS)
|
|
2278
|
+
deps.append(
|
|
2279
|
+
ExtractedDependency(
|
|
2280
|
+
name=cls.name,
|
|
2281
|
+
qualified_name=cls.qualified_name,
|
|
2282
|
+
location=cls.location,
|
|
2283
|
+
dependency_type="class",
|
|
2284
|
+
provides_type=cls.name,
|
|
2285
|
+
is_auth_related=is_auth,
|
|
2286
|
+
confidence=Confidence.HIGH,
|
|
2287
|
+
)
|
|
2288
|
+
)
|
|
2289
|
+
return deps
|
|
2290
|
+
|
|
2291
|
+
# -------------------------------------------------------------------------
|
|
2292
|
+
# Middleware
|
|
2293
|
+
# -------------------------------------------------------------------------
|
|
2294
|
+
|
|
2295
|
+
def extract_middleware(self, parsed_file: ParsedFile) -> list[ExtractedMiddleware]:
|
|
2296
|
+
middleware: list[ExtractedMiddleware] = []
|
|
2297
|
+
|
|
2298
|
+
for cls in parsed_file.classes:
|
|
2299
|
+
mw_type = self._classify_middleware(cls)
|
|
2300
|
+
if mw_type:
|
|
2301
|
+
ops = self._infer_operations(cls)
|
|
2302
|
+
middleware.append(
|
|
2303
|
+
ExtractedMiddleware(
|
|
2304
|
+
name=cls.name,
|
|
2305
|
+
qualified_name=cls.qualified_name,
|
|
2306
|
+
location=cls.location,
|
|
2307
|
+
middleware_type=mw_type,
|
|
2308
|
+
# A class definition alone does NOT mean it is globally registered.
|
|
2309
|
+
# Only explicit app.UseMiddleware<T>() call sites set applies_to_all=True.
|
|
2310
|
+
applies_to_all=False,
|
|
2311
|
+
operations=ops,
|
|
2312
|
+
confidence=Confidence.MEDIUM,
|
|
2313
|
+
)
|
|
2314
|
+
)
|
|
2315
|
+
|
|
2316
|
+
# Detect app.UseMiddleware<T>() call sites
|
|
2317
|
+
for call in parsed_file.call_sites:
|
|
2318
|
+
callee = call.callee_name
|
|
2319
|
+
callee_lower = callee.lower()
|
|
2320
|
+
if "usemiddleware" in callee_lower:
|
|
2321
|
+
middleware.append(
|
|
2322
|
+
ExtractedMiddleware(
|
|
2323
|
+
name=callee,
|
|
2324
|
+
qualified_name=_str_to_qname(callee),
|
|
2325
|
+
location=call.location,
|
|
2326
|
+
middleware_type="middleware",
|
|
2327
|
+
applies_to_all=True,
|
|
2328
|
+
operations=["custom"],
|
|
2329
|
+
confidence=Confidence.MEDIUM,
|
|
2330
|
+
)
|
|
2331
|
+
)
|
|
2332
|
+
elif "usecors" in callee_lower:
|
|
2333
|
+
middleware.append(
|
|
2334
|
+
ExtractedMiddleware(
|
|
2335
|
+
name="CORS",
|
|
2336
|
+
qualified_name=_str_to_qname(callee),
|
|
2337
|
+
location=call.location,
|
|
2338
|
+
middleware_type="cors",
|
|
2339
|
+
applies_to_all=True,
|
|
2340
|
+
operations=["cors"],
|
|
2341
|
+
confidence=Confidence.HIGH,
|
|
2342
|
+
)
|
|
2343
|
+
)
|
|
2344
|
+
elif "useauthentication" in callee_lower:
|
|
2345
|
+
middleware.append(
|
|
2346
|
+
ExtractedMiddleware(
|
|
2347
|
+
name="Authentication",
|
|
2348
|
+
qualified_name=_str_to_qname(callee),
|
|
2349
|
+
location=call.location,
|
|
2350
|
+
middleware_type="auth",
|
|
2351
|
+
applies_to_all=True,
|
|
2352
|
+
# "auth_pipeline" rather than "auth": UseAuthentication only
|
|
2353
|
+
# reads the identity — it does not block unauthenticated requests
|
|
2354
|
+
# globally, so it must not be treated as a globally-enforcing guard.
|
|
2355
|
+
operations=["auth_pipeline"],
|
|
2356
|
+
confidence=Confidence.HIGH,
|
|
2357
|
+
)
|
|
2358
|
+
)
|
|
2359
|
+
elif "useauthorization" in callee_lower:
|
|
2360
|
+
middleware.append(
|
|
2361
|
+
ExtractedMiddleware(
|
|
2362
|
+
name="Authorization",
|
|
2363
|
+
qualified_name=_str_to_qname(callee),
|
|
2364
|
+
location=call.location,
|
|
2365
|
+
middleware_type="auth",
|
|
2366
|
+
applies_to_all=True,
|
|
2367
|
+
# "auth_pipeline": UseAuthorization enables the authorization
|
|
2368
|
+
# framework but only enforces auth where [Authorize] is present.
|
|
2369
|
+
operations=["auth_pipeline"],
|
|
2370
|
+
confidence=Confidence.HIGH,
|
|
2371
|
+
)
|
|
2372
|
+
)
|
|
2373
|
+
elif "usehttpsredirection" in callee_lower:
|
|
2374
|
+
middleware.append(
|
|
2375
|
+
ExtractedMiddleware(
|
|
2376
|
+
name="HttpsRedirection",
|
|
2377
|
+
qualified_name=_str_to_qname(callee),
|
|
2378
|
+
location=call.location,
|
|
2379
|
+
middleware_type="https_redirect",
|
|
2380
|
+
applies_to_all=True,
|
|
2381
|
+
operations=["https_redirect"],
|
|
2382
|
+
confidence=Confidence.HIGH,
|
|
2383
|
+
)
|
|
2384
|
+
)
|
|
2385
|
+
|
|
2386
|
+
return middleware
|
|
2387
|
+
|
|
2388
|
+
def _classify_middleware(self, cls: ParsedClass) -> str | None:
|
|
2389
|
+
for base in cls.base_classes:
|
|
2390
|
+
if base in ("IMiddleware", "IApplicationBuilder"):
|
|
2391
|
+
return "middleware"
|
|
2392
|
+
if "Filter" in base or base in (
|
|
2393
|
+
"IActionFilter",
|
|
2394
|
+
"IAuthorizationFilter",
|
|
2395
|
+
"IResourceFilter",
|
|
2396
|
+
"IResultFilter",
|
|
2397
|
+
"IExceptionFilter",
|
|
2398
|
+
):
|
|
2399
|
+
return "filter"
|
|
2400
|
+
name_lower = cls.name.lower()
|
|
2401
|
+
if "middleware" in name_lower:
|
|
2402
|
+
return "middleware"
|
|
2403
|
+
if "filter" in name_lower:
|
|
2404
|
+
return "filter"
|
|
2405
|
+
return None
|
|
2406
|
+
|
|
2407
|
+
def _infer_operations(self, cls: ParsedClass) -> list[str]:
|
|
2408
|
+
combined = cls.name.lower() + " ".join(b.lower() for b in cls.base_classes)
|
|
2409
|
+
ops: list[str] = []
|
|
2410
|
+
if any(kw in combined for kw in ["auth", "jwt", "token", "security", "login"]):
|
|
2411
|
+
ops.append("auth")
|
|
2412
|
+
if "cors" in combined:
|
|
2413
|
+
ops.append("cors")
|
|
2414
|
+
if "log" in combined:
|
|
2415
|
+
ops.append("logging")
|
|
2416
|
+
if "rate" in combined or "throttl" in combined:
|
|
2417
|
+
ops.append("rate_limiting")
|
|
2418
|
+
return ops or ["custom"]
|
|
2419
|
+
|
|
2420
|
+
# -------------------------------------------------------------------------
|
|
2421
|
+
# JWT config extraction
|
|
2422
|
+
# -------------------------------------------------------------------------
|
|
2423
|
+
|
|
2424
|
+
_jwt_extractor = DotNetJwtConfigExtractor()
|
|
2425
|
+
|
|
2426
|
+
def extract_jwt_config(
|
|
2427
|
+
self,
|
|
2428
|
+
parsed_file: ParsedFile,
|
|
2429
|
+
context: AnalysisContext | None = None,
|
|
2430
|
+
) -> ExtractedJwtConfig | None:
|
|
2431
|
+
return self._jwt_extractor.extract(parsed_file)
|
|
2432
|
+
|
|
2433
|
+
# -------------------------------------------------------------------------
|
|
2434
|
+
# Annotation helpers
|
|
2435
|
+
# -------------------------------------------------------------------------
|
|
2436
|
+
|
|
2437
|
+
def _dec_path(self, dec: ParsedDecorator) -> str | None:
|
|
2438
|
+
"""Extract path string from a routing attribute."""
|
|
2439
|
+
if dec.positional_args:
|
|
2440
|
+
val = dec.positional_args[0]
|
|
2441
|
+
if isinstance(val, str):
|
|
2442
|
+
return val
|
|
2443
|
+
for key in ("template", "Template", "name", "Name"):
|
|
2444
|
+
val = dec.arguments.get(key)
|
|
2445
|
+
if val and isinstance(val, str):
|
|
2446
|
+
return val
|
|
2447
|
+
return None
|
|
2448
|
+
|
|
2449
|
+
def _join_paths(self, prefix: str, path: str) -> str:
|
|
2450
|
+
if path.startswith("/"):
|
|
2451
|
+
return path # absolute path override ignores controller prefix
|
|
2452
|
+
prefix = prefix.rstrip("/")
|
|
2453
|
+
result = (prefix + "/" + path).rstrip("/") if path else prefix
|
|
2454
|
+
if not result:
|
|
2455
|
+
return "/"
|
|
2456
|
+
if not result.startswith("/"):
|
|
2457
|
+
result = "/" + result
|
|
2458
|
+
return result
|
|
2459
|
+
|
|
2460
|
+
@staticmethod
|
|
2461
|
+
def _normalize_path(path: str) -> str:
|
|
2462
|
+
"""Strip route constraint suffixes: {id:int} → {id}, {slug:regex(...)} → {slug}."""
|
|
2463
|
+
return re.sub(r"\{([^}:]+):[^}]+\}", r"{\1}", path)
|
|
2464
|
+
|
|
2465
|
+
def _extract_response_status(self, dec: ParsedDecorator) -> int:
|
|
2466
|
+
for val in dec.positional_args:
|
|
2467
|
+
if isinstance(val, int):
|
|
2468
|
+
return val
|
|
2469
|
+
if isinstance(val, str):
|
|
2470
|
+
code = _STATUS_NAMES.get(val)
|
|
2471
|
+
if code:
|
|
2472
|
+
return code
|
|
2473
|
+
try:
|
|
2474
|
+
return int(val)
|
|
2475
|
+
except ValueError:
|
|
2476
|
+
pass
|
|
2477
|
+
for key in ("StatusCode", "statusCode", "Type", "type"):
|
|
2478
|
+
val = dec.arguments.get(key)
|
|
2479
|
+
if val:
|
|
2480
|
+
n = _STATUS_NAMES.get(str(val))
|
|
2481
|
+
if n:
|
|
2482
|
+
return n
|
|
2483
|
+
return 200
|
|
2484
|
+
|
|
2485
|
+
|
|
2486
|
+
def _method_is_public_csharp(method: ParsedFunction) -> bool:
|
|
2487
|
+
"""
|
|
2488
|
+
Best-effort access-modifier detection from the method's signature
|
|
2489
|
+
line. `ParsedFunction` doesn't track `access_modifier` for methods
|
|
2490
|
+
(only `ParsedField` does), so we read the source file at
|
|
2491
|
+
`method.location` and look for the `public` keyword on the
|
|
2492
|
+
signature line.
|
|
2493
|
+
|
|
2494
|
+
`method.location.line` may point at the topmost decorator line
|
|
2495
|
+
rather than the signature for attributed methods, so we scan
|
|
2496
|
+
forward for the first line containing `<MethodName>(`.
|
|
2497
|
+
|
|
2498
|
+
Returns True defensively when:
|
|
2499
|
+
- The location has no file path (in-memory `parse_source` with no
|
|
2500
|
+
`file_path=` — e.g. unit tests that construct fixtures inline).
|
|
2501
|
+
These tests have always behaved as if visibility were not
|
|
2502
|
+
enforced; preserve that.
|
|
2503
|
+
- The file isn't readable (network/permissions error).
|
|
2504
|
+
- The signature line can't be located within the next 15 lines.
|
|
2505
|
+
|
|
2506
|
+
Returns False only when an explicit `private`/`protected`/`internal`
|
|
2507
|
+
modifier is present, or when no modifier appears at all (C#
|
|
2508
|
+
in-class default is `private`).
|
|
2509
|
+
"""
|
|
2510
|
+
if method.location is None or method.location.file is None:
|
|
2511
|
+
return True
|
|
2512
|
+
try:
|
|
2513
|
+
text = Path(method.location.file).read_text(
|
|
2514
|
+
encoding="utf-8",
|
|
2515
|
+
errors="replace",
|
|
2516
|
+
)
|
|
2517
|
+
except OSError:
|
|
2518
|
+
return True
|
|
2519
|
+
lines = text.splitlines()
|
|
2520
|
+
# Treat the unit-test in-memory case (file path looks synthetic /
|
|
2521
|
+
# virtual / nonexistent on disk) as "visibility unknown — include."
|
|
2522
|
+
if not lines:
|
|
2523
|
+
return True
|
|
2524
|
+
line_idx = method.location.line - 1
|
|
2525
|
+
if line_idx < 0:
|
|
2526
|
+
return True
|
|
2527
|
+
name_re = re.compile(rf"\b{re.escape(method.name)}\s*\(")
|
|
2528
|
+
for i in range(line_idx, min(len(lines), line_idx + 15)):
|
|
2529
|
+
if not name_re.search(lines[i]):
|
|
2530
|
+
continue
|
|
2531
|
+
pre_paren = lines[i].split("(", 1)[0]
|
|
2532
|
+
tokens = set(re.findall(r"\b\w+\b", pre_paren))
|
|
2533
|
+
if "public" in tokens:
|
|
2534
|
+
return True
|
|
2535
|
+
if tokens & {"private", "protected", "internal"}:
|
|
2536
|
+
return False
|
|
2537
|
+
return False # No explicit modifier — C# default is private
|
|
2538
|
+
return True # Couldn't locate signature line — defensive
|
|
2539
|
+
|
|
2540
|
+
|
|
2541
|
+
# =============================================================================
|
|
2542
|
+
# Registration
|
|
2543
|
+
# =============================================================================
|
|
2544
|
+
|
|
2545
|
+
_aspnet_plugin = AspNetCorePlugin()
|
|
2546
|
+
FrameworkPluginRegistry.register(_aspnet_plugin)
|