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,6 @@
|
|
|
1
|
+
"""Java framework plugins (Spring Boot, Micronaut, JAX-RS, GraphQL)."""
|
|
2
|
+
|
|
3
|
+
from .graphql_plugin import GraphQLJavaPlugin # noqa: F401 — triggers registration
|
|
4
|
+
from .jaxrs_plugin import JaxRsPlugin # noqa: F401 — triggers registration
|
|
5
|
+
from .micronaut_plugin import MicronautPlugin # noqa: F401 — triggers registration
|
|
6
|
+
from .spring_plugin import SpringBootPlugin # noqa: F401 — triggers registration
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared annotation-decoding helpers for Java framework plugins.
|
|
3
|
+
|
|
4
|
+
Spring Boot and Micronaut both decode `ParsedDecorator` objects produced
|
|
5
|
+
by the JVM parser in the same handful of patterns:
|
|
6
|
+
- "extract one or more path strings from a mapping annotation"
|
|
7
|
+
- "extract a single string attribute by name"
|
|
8
|
+
- "extract a string-list attribute by name"
|
|
9
|
+
- "resolve a status-enum-or-int to an HTTP status code"
|
|
10
|
+
- "join a class-level prefix with a method-level path"
|
|
11
|
+
- "unpack a parameter annotation into (alias, required, default)"
|
|
12
|
+
|
|
13
|
+
The two plugins had near-byte-identical copies of these helpers before
|
|
14
|
+
this module existed. Both now delegate here.
|
|
15
|
+
|
|
16
|
+
The only meaningful per-framework difference is the *set of keys* under
|
|
17
|
+
which Spring vs Micronaut store the path string on mapping annotations:
|
|
18
|
+
- Spring: ``value`` / ``path`` (@GetMapping(path = "/x"))
|
|
19
|
+
- Micronaut: ``value`` / ``uri`` / ``uris`` (@Get(uri = "/x"))
|
|
20
|
+
|
|
21
|
+
`annotation_paths()` takes the keys to look up as an argument.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from ...parsing.base import ParsedDecorator
|
|
29
|
+
|
|
30
|
+
# Default key names — Spring uses these. Micronaut callers supply their own.
|
|
31
|
+
DEFAULT_PATH_KEYS: tuple[str, ...] = ("value", "path")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def annotation_paths(
|
|
35
|
+
dec: ParsedDecorator,
|
|
36
|
+
keys: tuple[str, ...] = DEFAULT_PATH_KEYS,
|
|
37
|
+
) -> list[str]:
|
|
38
|
+
"""Extract ALL path values from a mapping annotation.
|
|
39
|
+
|
|
40
|
+
Handles both single-value and multi-value forms::
|
|
41
|
+
|
|
42
|
+
@GetMapping("/users") → ["/users"]
|
|
43
|
+
@GetMapping({"/v1/users", "/v2"}) → ["/v1/users", "/v2"]
|
|
44
|
+
@GetMapping(value = {"/a", "/b"}) → ["/a", "/b"]
|
|
45
|
+
"""
|
|
46
|
+
if dec.positional_args:
|
|
47
|
+
val = dec.positional_args[0]
|
|
48
|
+
if isinstance(val, str):
|
|
49
|
+
return [val]
|
|
50
|
+
if isinstance(val, list):
|
|
51
|
+
return [str(v) for v in val if v]
|
|
52
|
+
|
|
53
|
+
for key in keys:
|
|
54
|
+
val = dec.arguments.get(key)
|
|
55
|
+
if val is not None:
|
|
56
|
+
if isinstance(val, str):
|
|
57
|
+
return [val]
|
|
58
|
+
if isinstance(val, list):
|
|
59
|
+
return [str(v) for v in val if v]
|
|
60
|
+
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def annotation_path(
|
|
65
|
+
dec: ParsedDecorator,
|
|
66
|
+
keys: tuple[str, ...] = DEFAULT_PATH_KEYS,
|
|
67
|
+
) -> str | None:
|
|
68
|
+
"""Return the first path from a mapping annotation, or None."""
|
|
69
|
+
paths = annotation_paths(dec, keys)
|
|
70
|
+
return paths[0] if paths else None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def annotation_str(dec: ParsedDecorator, key: str) -> str | None:
|
|
74
|
+
"""Extract a single string attribute from an annotation by key name."""
|
|
75
|
+
val = dec.arguments.get(key)
|
|
76
|
+
if val is None:
|
|
77
|
+
return None
|
|
78
|
+
if isinstance(val, str):
|
|
79
|
+
return val
|
|
80
|
+
if isinstance(val, list) and val:
|
|
81
|
+
return str(val[0])
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def annotation_str_list(dec: ParsedDecorator, key: str) -> list[str]:
|
|
86
|
+
"""Extract a string-list attribute from an annotation by key name."""
|
|
87
|
+
val = dec.arguments.get(key)
|
|
88
|
+
if val is None:
|
|
89
|
+
return []
|
|
90
|
+
if isinstance(val, str):
|
|
91
|
+
return [val]
|
|
92
|
+
if isinstance(val, list):
|
|
93
|
+
return [str(v) for v in val]
|
|
94
|
+
return []
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def extract_status_code(
|
|
98
|
+
dec: ParsedDecorator,
|
|
99
|
+
status_map: dict[str, int],
|
|
100
|
+
default: int = 200,
|
|
101
|
+
) -> int:
|
|
102
|
+
"""Extract HTTP status from a status annotation.
|
|
103
|
+
|
|
104
|
+
Handles all three forms::
|
|
105
|
+
|
|
106
|
+
@ResponseStatus(HttpStatus.CREATED) → 201
|
|
107
|
+
@ResponseStatus(code = HttpStatus.NO_CONTENT) → 204
|
|
108
|
+
@ResponseStatus(value = 201) → 201
|
|
109
|
+
|
|
110
|
+
``status_map`` maps the upper-cased name segment after the last dot
|
|
111
|
+
(e.g. "CREATED" from "HttpStatus.CREATED") to an int status code.
|
|
112
|
+
"""
|
|
113
|
+
candidates: list[Any] = list(dec.positional_args)
|
|
114
|
+
for attr in ("code", "value"):
|
|
115
|
+
v = dec.arguments.get(attr)
|
|
116
|
+
if v is not None:
|
|
117
|
+
candidates.append(v)
|
|
118
|
+
|
|
119
|
+
for val in candidates:
|
|
120
|
+
if isinstance(val, int):
|
|
121
|
+
return val
|
|
122
|
+
if isinstance(val, str):
|
|
123
|
+
s = val.split(".")[-1].upper()
|
|
124
|
+
code = status_map.get(s)
|
|
125
|
+
if code is not None:
|
|
126
|
+
return code
|
|
127
|
+
|
|
128
|
+
return default
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def join_paths(prefix: str, path: str) -> str:
|
|
132
|
+
"""Combine a class-level prefix with a method-level path."""
|
|
133
|
+
prefix = prefix.rstrip("/")
|
|
134
|
+
if path and not path.startswith("/"):
|
|
135
|
+
path = "/" + path
|
|
136
|
+
result = prefix + path
|
|
137
|
+
if result and not result.startswith("/"):
|
|
138
|
+
result = "/" + result
|
|
139
|
+
return result if result else "/"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def unpack_annotation_alias(val: Any, param_name: str) -> tuple[str, bool, Any]:
|
|
143
|
+
"""Unpack a parameter annotation value into (alias, required, default).
|
|
144
|
+
|
|
145
|
+
The JVM parser stores parameter annotation values in two forms:
|
|
146
|
+
|
|
147
|
+
- Bare string for positional args::
|
|
148
|
+
|
|
149
|
+
@RequestParam("q") → val = "q"
|
|
150
|
+
|
|
151
|
+
- Dict for named args::
|
|
152
|
+
|
|
153
|
+
@RequestParam(name="q", required=false, defaultValue="0")
|
|
154
|
+
→ val = {"name": "q", "required": "false", "defaultValue": "0"}
|
|
155
|
+
|
|
156
|
+
Booleans may be stored as the strings ``"true"`` / ``"false"``.
|
|
157
|
+
Returns ``(param_name, True, None)`` when ``val`` is empty or not a string/dict.
|
|
158
|
+
"""
|
|
159
|
+
if isinstance(val, dict):
|
|
160
|
+
alias = val.get("name") or val.get("value") or param_name
|
|
161
|
+
req_raw = val.get("required", "true")
|
|
162
|
+
required = req_raw.lower() != "false" if isinstance(req_raw, str) else bool(req_raw)
|
|
163
|
+
default_value = val.get("defaultValue")
|
|
164
|
+
return alias, required, default_value
|
|
165
|
+
if isinstance(val, str) and val:
|
|
166
|
+
return val, True, None
|
|
167
|
+
return param_name, True, None
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared Bean Validation (JSR-303/380) constraint extraction.
|
|
3
|
+
|
|
4
|
+
Both Spring Boot and Micronaut use the same `jakarta.validation` /
|
|
5
|
+
`javax.validation` annotations (@NotNull, @Size, @Min, @Max, @Pattern,
|
|
6
|
+
@Email, etc.). The mapping to the canonical constraints dict is identical,
|
|
7
|
+
so we share the implementation here rather than duplicating it across
|
|
8
|
+
both plugins.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def to_number(val: Any) -> int | float | None:
|
|
17
|
+
"""Coerce an annotation attribute value to int or float.
|
|
18
|
+
|
|
19
|
+
javalang stores numeric literals as strings (e.g. "3"), so we parse
|
|
20
|
+
them before storing in the constraints dict.
|
|
21
|
+
"""
|
|
22
|
+
if isinstance(val, (int, float)) and not isinstance(val, bool):
|
|
23
|
+
return val
|
|
24
|
+
if isinstance(val, str):
|
|
25
|
+
try:
|
|
26
|
+
return int(val)
|
|
27
|
+
except ValueError:
|
|
28
|
+
pass
|
|
29
|
+
try:
|
|
30
|
+
return float(val)
|
|
31
|
+
except ValueError:
|
|
32
|
+
pass
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def extract_constraints(metadata: dict[str, Any]) -> dict[str, Any]:
|
|
37
|
+
"""Build a constraints dict from Bean Validation annotations in param metadata."""
|
|
38
|
+
constraints: dict[str, Any] = {}
|
|
39
|
+
|
|
40
|
+
if "NotNull" in metadata or "NonNull" in metadata:
|
|
41
|
+
constraints["not_null"] = True
|
|
42
|
+
if "NotEmpty" in metadata:
|
|
43
|
+
constraints["not_empty"] = True
|
|
44
|
+
if "NotBlank" in metadata:
|
|
45
|
+
constraints["not_blank"] = True
|
|
46
|
+
|
|
47
|
+
# @Size(min=1, max=255) or @Length(min=1, max=255)
|
|
48
|
+
for ann in ("Size", "Length"):
|
|
49
|
+
if ann in metadata:
|
|
50
|
+
val = metadata[ann]
|
|
51
|
+
if isinstance(val, dict):
|
|
52
|
+
if "min" in val:
|
|
53
|
+
n = to_number(val["min"])
|
|
54
|
+
if n is not None:
|
|
55
|
+
constraints["min_length"] = n
|
|
56
|
+
if "max" in val:
|
|
57
|
+
n = to_number(val["max"])
|
|
58
|
+
if n is not None:
|
|
59
|
+
constraints["max_length"] = n
|
|
60
|
+
break
|
|
61
|
+
|
|
62
|
+
for attr, key in (
|
|
63
|
+
("Min", "min"),
|
|
64
|
+
("Max", "max"),
|
|
65
|
+
("DecimalMin", "decimal_min"),
|
|
66
|
+
("DecimalMax", "decimal_max"),
|
|
67
|
+
):
|
|
68
|
+
if attr in metadata:
|
|
69
|
+
val = metadata[attr]
|
|
70
|
+
raw = val.get("value", val) if isinstance(val, dict) else val
|
|
71
|
+
n = to_number(raw)
|
|
72
|
+
if n is not None:
|
|
73
|
+
constraints[key] = n
|
|
74
|
+
|
|
75
|
+
# @Pattern(regexp="...")
|
|
76
|
+
if "Pattern" in metadata:
|
|
77
|
+
val = metadata["Pattern"]
|
|
78
|
+
if isinstance(val, dict):
|
|
79
|
+
constraints["pattern"] = val.get("regexp", val.get("value", ""))
|
|
80
|
+
elif isinstance(val, str):
|
|
81
|
+
constraints["pattern"] = val
|
|
82
|
+
|
|
83
|
+
if "Email" in metadata:
|
|
84
|
+
constraints["format"] = "email"
|
|
85
|
+
if "URL" in metadata:
|
|
86
|
+
constraints["format"] = "url"
|
|
87
|
+
|
|
88
|
+
# @Positive / @PositiveOrZero / @Negative / @NegativeOrZero
|
|
89
|
+
# setdefault preserves explicit @Min/@Max when both are present.
|
|
90
|
+
if "Positive" in metadata:
|
|
91
|
+
constraints.setdefault("min", 1)
|
|
92
|
+
if "PositiveOrZero" in metadata:
|
|
93
|
+
constraints.setdefault("min", 0)
|
|
94
|
+
if "Negative" in metadata:
|
|
95
|
+
constraints.setdefault("max", -1)
|
|
96
|
+
if "NegativeOrZero" in metadata:
|
|
97
|
+
constraints.setdefault("max", 0)
|
|
98
|
+
|
|
99
|
+
return constraints
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
_SIMPLE_JAVA_TYPES: frozenset[str] = frozenset(
|
|
103
|
+
{
|
|
104
|
+
"String",
|
|
105
|
+
"string",
|
|
106
|
+
"int",
|
|
107
|
+
"Integer",
|
|
108
|
+
"long",
|
|
109
|
+
"Long",
|
|
110
|
+
"boolean",
|
|
111
|
+
"Boolean",
|
|
112
|
+
"double",
|
|
113
|
+
"Double",
|
|
114
|
+
"float",
|
|
115
|
+
"Float",
|
|
116
|
+
"byte",
|
|
117
|
+
"Byte",
|
|
118
|
+
"char",
|
|
119
|
+
"Character",
|
|
120
|
+
"short",
|
|
121
|
+
"Short",
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def is_complex_type(type_name: str) -> bool:
|
|
127
|
+
"""Heuristic: scalar Java types vs. objects/generics."""
|
|
128
|
+
return type_name not in _SIMPLE_JAVA_TYPES
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Java GraphQL framework plugin.
|
|
3
|
+
|
|
4
|
+
Supports:
|
|
5
|
+
- Spring for GraphQL: @QueryMapping, @MutationMapping, @SubscriptionMapping,
|
|
6
|
+
@SchemaMapping, @BatchMapping annotations on controller methods
|
|
7
|
+
- graphql-java-kickstart: classes implementing GraphQLQueryResolver,
|
|
8
|
+
GraphQLMutationResolver, GraphQLSubscriptionResolver — all public methods
|
|
9
|
+
become operations
|
|
10
|
+
- Auth: @PreAuthorize, @Secured, @RolesAllowed captured as router_name
|
|
11
|
+
|
|
12
|
+
Path format: /graphql:Query.methodName
|
|
13
|
+
HTTP method: POST for Mutation/Subscription, GET otherwise
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
from ...core.types import (
|
|
22
|
+
Framework,
|
|
23
|
+
HttpMethod,
|
|
24
|
+
Language,
|
|
25
|
+
QualifiedName,
|
|
26
|
+
)
|
|
27
|
+
from ...parsing.base import ParsedFile
|
|
28
|
+
from ..base import (
|
|
29
|
+
BaseFrameworkPlugin,
|
|
30
|
+
ExtractedAuthDependency,
|
|
31
|
+
ExtractedAuthScheme,
|
|
32
|
+
ExtractedDependency,
|
|
33
|
+
ExtractedMiddleware,
|
|
34
|
+
ExtractedRoute,
|
|
35
|
+
FrameworkPluginRegistry,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from ...parsing.services import AnalysisContext
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
# Spring for GraphQL annotation names
|
|
44
|
+
_SPRING_QUERY_ANNOTATIONS = frozenset(
|
|
45
|
+
{
|
|
46
|
+
"QueryMapping",
|
|
47
|
+
"MutationMapping",
|
|
48
|
+
"SubscriptionMapping",
|
|
49
|
+
"SchemaMapping",
|
|
50
|
+
"BatchMapping",
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Kickstart resolver base interfaces
|
|
55
|
+
_KICKSTART_RESOLVER_BASES = frozenset(
|
|
56
|
+
{
|
|
57
|
+
"GraphQLQueryResolver",
|
|
58
|
+
"GraphQLMutationResolver",
|
|
59
|
+
"GraphQLSubscriptionResolver",
|
|
60
|
+
"GraphQLResolver",
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Auth annotations
|
|
65
|
+
_AUTH_ANNOTATIONS = frozenset(
|
|
66
|
+
{
|
|
67
|
+
"PreAuthorize",
|
|
68
|
+
"Secured",
|
|
69
|
+
"RolesAllowed",
|
|
70
|
+
"PermitAll",
|
|
71
|
+
"DenyAll",
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Imports identifying Spring GraphQL
|
|
76
|
+
_SPRING_GQL_IMPORTS = frozenset(
|
|
77
|
+
{
|
|
78
|
+
"org.springframework.graphql.data.method.annotation",
|
|
79
|
+
"org.springframework.graphql",
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Imports identifying graphql-java-kickstart
|
|
84
|
+
_KICKSTART_IMPORTS = frozenset(
|
|
85
|
+
{
|
|
86
|
+
"graphql.kickstart.tools",
|
|
87
|
+
"com.coxautodev.graphql.tools",
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
_ALL_DETECTION_IMPORTS = _SPRING_GQL_IMPORTS | _KICKSTART_IMPORTS | frozenset({"graphql"})
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _gql_path(operation_type: str, field_name: str) -> str:
|
|
95
|
+
"""Internal path token for a GraphQL operation: /graphql:<OperationType>.<fieldName>.
|
|
96
|
+
|
|
97
|
+
Not a real HTTP URL — downstream consumers translate to POST /graphql with
|
|
98
|
+
the appropriate query/mutation body. Query → GET; Mutation/Subscription → POST.
|
|
99
|
+
"""
|
|
100
|
+
return f"/graphql:{operation_type}.{field_name}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _gql_http_method(operation_type: str) -> HttpMethod:
|
|
104
|
+
if operation_type.lower() in ("mutation", "subscription"):
|
|
105
|
+
return HttpMethod.POST
|
|
106
|
+
return HttpMethod.GET
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _annotation_to_operation(annotation_name: str) -> str:
|
|
110
|
+
"""Map annotation name to GraphQL operation type."""
|
|
111
|
+
if annotation_name in ("MutationMapping",):
|
|
112
|
+
return "Mutation"
|
|
113
|
+
if annotation_name in ("SubscriptionMapping",):
|
|
114
|
+
return "Subscription"
|
|
115
|
+
return "Query" # QueryMapping, SchemaMapping, BatchMapping → Query
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class GraphQLJavaPlugin(BaseFrameworkPlugin):
|
|
119
|
+
"""
|
|
120
|
+
Framework plugin for Java GraphQL frameworks.
|
|
121
|
+
|
|
122
|
+
Detects Spring for GraphQL and graphql-java-kickstart and extracts
|
|
123
|
+
GraphQL operations as routes.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
FRAMEWORK = Framework.GRAPHQL
|
|
127
|
+
LANGUAGE = Language.JAVA
|
|
128
|
+
DETECTION_IMPORTS: frozenset[str] = _ALL_DETECTION_IMPORTS
|
|
129
|
+
|
|
130
|
+
def detect(self, parsed_file: ParsedFile) -> bool:
|
|
131
|
+
"""Detect Java GraphQL via imports."""
|
|
132
|
+
for imp in parsed_file.imports:
|
|
133
|
+
if imp.module.startswith("org.springframework.graphql"):
|
|
134
|
+
return True
|
|
135
|
+
if imp.module.startswith("graphql.kickstart"):
|
|
136
|
+
return True
|
|
137
|
+
if imp.module.startswith("com.coxautodev.graphql"):
|
|
138
|
+
return True
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
def extract_routes(
|
|
142
|
+
self,
|
|
143
|
+
parsed_file: ParsedFile,
|
|
144
|
+
context: AnalysisContext | None = None,
|
|
145
|
+
) -> list[ExtractedRoute]:
|
|
146
|
+
"""Extract GraphQL operations as routes."""
|
|
147
|
+
routes: list[ExtractedRoute] = []
|
|
148
|
+
|
|
149
|
+
# Detect which library is in use
|
|
150
|
+
has_spring = any(
|
|
151
|
+
imp.module.startswith("org.springframework.graphql") for imp in parsed_file.imports
|
|
152
|
+
)
|
|
153
|
+
has_kickstart = any(
|
|
154
|
+
imp.module.startswith("graphql.kickstart")
|
|
155
|
+
or imp.module.startswith("com.coxautodev.graphql")
|
|
156
|
+
for imp in parsed_file.imports
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if has_spring:
|
|
160
|
+
routes.extend(self._extract_spring(parsed_file))
|
|
161
|
+
if has_kickstart:
|
|
162
|
+
routes.extend(self._extract_kickstart(parsed_file))
|
|
163
|
+
|
|
164
|
+
return routes
|
|
165
|
+
|
|
166
|
+
def _extract_spring(self, parsed_file: ParsedFile) -> list[ExtractedRoute]:
|
|
167
|
+
"""Extract Spring for GraphQL operations."""
|
|
168
|
+
routes: list[ExtractedRoute] = []
|
|
169
|
+
|
|
170
|
+
for cls in parsed_file.classes:
|
|
171
|
+
# Only process @Controller classes (or just any class with GQL annotations)
|
|
172
|
+
for method in cls.methods:
|
|
173
|
+
gql_dec = None
|
|
174
|
+
for dec in method.decorators:
|
|
175
|
+
if dec.name in _SPRING_QUERY_ANNOTATIONS:
|
|
176
|
+
gql_dec = dec
|
|
177
|
+
break
|
|
178
|
+
|
|
179
|
+
if gql_dec is None:
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
operation_type = _annotation_to_operation(gql_dec.name)
|
|
183
|
+
|
|
184
|
+
# Field name: from annotation arg or method name
|
|
185
|
+
if gql_dec.positional_args:
|
|
186
|
+
field_name = str(gql_dec.positional_args[0])
|
|
187
|
+
else:
|
|
188
|
+
field_name = method.name
|
|
189
|
+
|
|
190
|
+
# Auth annotations
|
|
191
|
+
auth_guard: str | None = None
|
|
192
|
+
for auth_dec in method.decorators + cls.decorators:
|
|
193
|
+
if auth_dec.name in _AUTH_ANNOTATIONS:
|
|
194
|
+
auth_guard = auth_dec.name
|
|
195
|
+
if auth_dec.positional_args:
|
|
196
|
+
auth_guard = str(auth_dec.positional_args[0])
|
|
197
|
+
break
|
|
198
|
+
|
|
199
|
+
path = _gql_path(operation_type, field_name)
|
|
200
|
+
routes.append(
|
|
201
|
+
ExtractedRoute(
|
|
202
|
+
method=_gql_http_method(operation_type),
|
|
203
|
+
path=path,
|
|
204
|
+
handler_function=QualifiedName(
|
|
205
|
+
module=parsed_file.path.stem,
|
|
206
|
+
name=f"{cls.name}.{method.name}",
|
|
207
|
+
),
|
|
208
|
+
handler_location=method.location,
|
|
209
|
+
router_name=auth_guard,
|
|
210
|
+
kind="http",
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
return routes
|
|
215
|
+
|
|
216
|
+
def _extract_kickstart(self, parsed_file: ParsedFile) -> list[ExtractedRoute]:
|
|
217
|
+
"""Extract graphql-java-kickstart resolver operations."""
|
|
218
|
+
routes: list[ExtractedRoute] = []
|
|
219
|
+
|
|
220
|
+
for cls in parsed_file.classes:
|
|
221
|
+
# Check if class implements a resolver interface
|
|
222
|
+
resolver_type: str | None = None
|
|
223
|
+
for base in cls.base_classes:
|
|
224
|
+
base_simple = base.split(".")[-1]
|
|
225
|
+
if base_simple in _KICKSTART_RESOLVER_BASES:
|
|
226
|
+
resolver_type = base_simple
|
|
227
|
+
break
|
|
228
|
+
|
|
229
|
+
if resolver_type is None:
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
if "Mutation" in resolver_type:
|
|
233
|
+
operation_type = "Mutation"
|
|
234
|
+
elif "Subscription" in resolver_type:
|
|
235
|
+
operation_type = "Subscription"
|
|
236
|
+
else:
|
|
237
|
+
operation_type = "Query"
|
|
238
|
+
|
|
239
|
+
for method in cls.methods:
|
|
240
|
+
# Skip non-public and lifecycle methods
|
|
241
|
+
if method.name.startswith("_") or method.name in (
|
|
242
|
+
"hashCode",
|
|
243
|
+
"equals",
|
|
244
|
+
"toString",
|
|
245
|
+
"getClass",
|
|
246
|
+
):
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
path = _gql_path(operation_type, method.name)
|
|
250
|
+
routes.append(
|
|
251
|
+
ExtractedRoute(
|
|
252
|
+
method=_gql_http_method(operation_type),
|
|
253
|
+
path=path,
|
|
254
|
+
handler_function=QualifiedName(
|
|
255
|
+
module=parsed_file.path.stem,
|
|
256
|
+
name=f"{cls.name}.{method.name}",
|
|
257
|
+
),
|
|
258
|
+
handler_location=method.location,
|
|
259
|
+
kind="http",
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
return routes
|
|
264
|
+
|
|
265
|
+
def extract_dependencies(self, parsed_file: ParsedFile) -> list[ExtractedDependency]:
|
|
266
|
+
return []
|
|
267
|
+
|
|
268
|
+
def extract_auth_schemes(self, parsed_file: ParsedFile) -> list[ExtractedAuthScheme]:
|
|
269
|
+
from ..auth_helpers import extract_java_auth_schemes
|
|
270
|
+
|
|
271
|
+
return extract_java_auth_schemes(parsed_file)
|
|
272
|
+
|
|
273
|
+
def extract_auth_dependencies(
|
|
274
|
+
self,
|
|
275
|
+
parsed_file: ParsedFile,
|
|
276
|
+
known_scheme_names: set[str] | None = None,
|
|
277
|
+
**kwargs: Any,
|
|
278
|
+
) -> list[ExtractedAuthDependency]:
|
|
279
|
+
from ..auth_helpers import extract_java_auth_dependencies
|
|
280
|
+
|
|
281
|
+
return extract_java_auth_dependencies(parsed_file)
|
|
282
|
+
|
|
283
|
+
def extract_middleware(self, parsed_file: ParsedFile) -> list[ExtractedMiddleware]:
|
|
284
|
+
return []
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
FrameworkPluginRegistry.register(GraphQLJavaPlugin())
|