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.
Files changed (111) hide show
  1. apisec_code_bolt/__init__.py +42 -0
  2. apisec_code_bolt/__main__.py +11 -0
  3. apisec_code_bolt/analysis/__init__.py +96 -0
  4. apisec_code_bolt/analysis/analyzer.py +2309 -0
  5. apisec_code_bolt/analysis/binding_tracker.py +341 -0
  6. apisec_code_bolt/analysis/call_graph.py +1197 -0
  7. apisec_code_bolt/analysis/call_graph_types.py +332 -0
  8. apisec_code_bolt/analysis/call_resolver.py +988 -0
  9. apisec_code_bolt/analysis/capability_tagger.py +322 -0
  10. apisec_code_bolt/analysis/config_scanner.py +197 -0
  11. apisec_code_bolt/analysis/data_flow.py +1883 -0
  12. apisec_code_bolt/analysis/dependency_extractor.py +959 -0
  13. apisec_code_bolt/analysis/flow_analysis.py +1406 -0
  14. apisec_code_bolt/analysis/hof_catalog.py +61 -0
  15. apisec_code_bolt/analysis/integration_detector.py +1399 -0
  16. apisec_code_bolt/analysis/literal_scanner.py +300 -0
  17. apisec_code_bolt/analysis/path_normalizer.py +55 -0
  18. apisec_code_bolt/analysis/read_site_detector.py +310 -0
  19. apisec_code_bolt/analysis/request_patterns.py +162 -0
  20. apisec_code_bolt/analysis/sensitivity_classifier.py +224 -0
  21. apisec_code_bolt/analysis/sink_evidence.py +333 -0
  22. apisec_code_bolt/analysis/url_prefix_resolver.py +338 -0
  23. apisec_code_bolt/cli/__init__.py +5 -0
  24. apisec_code_bolt/cli/exit_codes.py +17 -0
  25. apisec_code_bolt/cli/main.py +1069 -0
  26. apisec_code_bolt/cloud/__init__.py +1 -0
  27. apisec_code_bolt/cloud/apisec_client.py +118 -0
  28. apisec_code_bolt/cloud/client.py +255 -0
  29. apisec_code_bolt/core/__init__.py +75 -0
  30. apisec_code_bolt/core/config.py +528 -0
  31. apisec_code_bolt/core/credentials.py +65 -0
  32. apisec_code_bolt/core/discovery.py +433 -0
  33. apisec_code_bolt/core/log_format.py +115 -0
  34. apisec_code_bolt/core/manifest.py +1009 -0
  35. apisec_code_bolt/core/repo.py +280 -0
  36. apisec_code_bolt/core/state.py +59 -0
  37. apisec_code_bolt/core/telemetry.py +451 -0
  38. apisec_code_bolt/core/types.py +587 -0
  39. apisec_code_bolt/fingerprinting/__init__.py +1 -0
  40. apisec_code_bolt/frameworks/__init__.py +29 -0
  41. apisec_code_bolt/frameworks/_jwt_common.py +50 -0
  42. apisec_code_bolt/frameworks/auth_helpers.py +437 -0
  43. apisec_code_bolt/frameworks/base.py +608 -0
  44. apisec_code_bolt/frameworks/dotnet/__init__.py +17 -0
  45. apisec_code_bolt/frameworks/dotnet/_path_helpers.py +43 -0
  46. apisec_code_bolt/frameworks/dotnet/aspnet_plugin.py +2546 -0
  47. apisec_code_bolt/frameworks/dotnet/grpc_plugin.py +559 -0
  48. apisec_code_bolt/frameworks/dotnet/jwt_config_extractor.py +545 -0
  49. apisec_code_bolt/frameworks/dotnet/legacy_aspnet_plugin.py +732 -0
  50. apisec_code_bolt/frameworks/dotnet/refit_plugin.py +374 -0
  51. apisec_code_bolt/frameworks/dotnet/wcf_plugin.py +1239 -0
  52. apisec_code_bolt/frameworks/java/__init__.py +6 -0
  53. apisec_code_bolt/frameworks/java/_annotations.py +167 -0
  54. apisec_code_bolt/frameworks/java/_constraints.py +128 -0
  55. apisec_code_bolt/frameworks/java/graphql_plugin.py +287 -0
  56. apisec_code_bolt/frameworks/java/jaxrs_plugin.py +748 -0
  57. apisec_code_bolt/frameworks/java/jwt_config_extractor.py +361 -0
  58. apisec_code_bolt/frameworks/java/micronaut_plugin.py +1059 -0
  59. apisec_code_bolt/frameworks/java/spring_plugin.py +1293 -0
  60. apisec_code_bolt/frameworks/js/__init__.py +8 -0
  61. apisec_code_bolt/frameworks/js/express_plugin.py +391 -0
  62. apisec_code_bolt/frameworks/js/fastify_plugin.py +381 -0
  63. apisec_code_bolt/frameworks/js/graphql_plugin.py +198 -0
  64. apisec_code_bolt/frameworks/js/nestjs_plugin.py +423 -0
  65. apisec_code_bolt/frameworks/python/__init__.py +19 -0
  66. apisec_code_bolt/frameworks/python/celery_plugin.py +393 -0
  67. apisec_code_bolt/frameworks/python/click_plugin.py +427 -0
  68. apisec_code_bolt/frameworks/python/django_plugin.py +867 -0
  69. apisec_code_bolt/frameworks/python/fastapi/__init__.py +28 -0
  70. apisec_code_bolt/frameworks/python/fastapi/plugin.py +1390 -0
  71. apisec_code_bolt/frameworks/python/flask_plugin.py +205 -0
  72. apisec_code_bolt/frameworks/python/graphql_plugin.py +274 -0
  73. apisec_code_bolt/frameworks/python/prefect_plugin.py +251 -0
  74. apisec_code_bolt/frameworks/python/webhook_plugin.py +255 -0
  75. apisec_code_bolt/parsing/__init__.py +62 -0
  76. apisec_code_bolt/parsing/base.py +554 -0
  77. apisec_code_bolt/parsing/csharp/__init__.py +5 -0
  78. apisec_code_bolt/parsing/csharp/language_services.py +203 -0
  79. apisec_code_bolt/parsing/csharp/literals.py +72 -0
  80. apisec_code_bolt/parsing/csharp/parser.py +1158 -0
  81. apisec_code_bolt/parsing/csharp/type_resolver.py +568 -0
  82. apisec_code_bolt/parsing/js/__init__.py +5 -0
  83. apisec_code_bolt/parsing/js/language_services.py +118 -0
  84. apisec_code_bolt/parsing/js/parser.py +622 -0
  85. apisec_code_bolt/parsing/jvm/__init__.py +7 -0
  86. apisec_code_bolt/parsing/jvm/language_services.py +270 -0
  87. apisec_code_bolt/parsing/jvm/parser.py +774 -0
  88. apisec_code_bolt/parsing/jvm/type_resolver.py +422 -0
  89. apisec_code_bolt/parsing/python/__init__.py +150 -0
  90. apisec_code_bolt/parsing/python/cbv_extractor.py +606 -0
  91. apisec_code_bolt/parsing/python/constant_resolver.py +500 -0
  92. apisec_code_bolt/parsing/python/cross_file_resolver.py +1054 -0
  93. apisec_code_bolt/parsing/python/dynamic_route_detector.py +532 -0
  94. apisec_code_bolt/parsing/python/expression_utils.py +221 -0
  95. apisec_code_bolt/parsing/python/extraction_types.py +271 -0
  96. apisec_code_bolt/parsing/python/language_services.py +487 -0
  97. apisec_code_bolt/parsing/python/parameter_analyzer.py +789 -0
  98. apisec_code_bolt/parsing/python/parser.py +719 -0
  99. apisec_code_bolt/parsing/python/path_resolver.py +576 -0
  100. apisec_code_bolt/parsing/python/router_registry.py +806 -0
  101. apisec_code_bolt/parsing/python/type_resolver.py +730 -0
  102. apisec_code_bolt/parsing/python/visitors.py +1544 -0
  103. apisec_code_bolt/parsing/services.py +544 -0
  104. apisec_code_bolt/query/__init__.py +1 -0
  105. apisec_code_bolt/query/ast_cache.py +182 -0
  106. apisec_code_bolt/query/executor.py +283 -0
  107. apisec_code_bolt/query/handlers.py +832 -0
  108. apisec_code_bolt-0.1.0.dist-info/METADATA +230 -0
  109. apisec_code_bolt-0.1.0.dist-info/RECORD +111 -0
  110. apisec_code_bolt-0.1.0.dist-info/WHEEL +4 -0
  111. 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())