From ae42addcb9c20c86c69b68cfd8a5dde6e2be65c2 Mon Sep 17 00:00:00 2001 From: JoseJacin Date: Wed, 24 Jun 2026 15:18:20 +0200 Subject: [PATCH 1/2] feat(parser): extract Java microservice config from annotations and YAML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New constants: - _REST_CLIENT_ANNOTATIONS: detects @RegisterRestClient - _HTTP_METHOD_ANNOTATIONS: @GET, @POST, @PUT, @DELETE, @PATCH, @HEAD, @OPTIONS - _REDIS_CHANNEL_ANNOTATIONS: @ConfigProperty New static method _get_annotation_arg(): extracts named or positional annotation argument values from the Tree-sitter AST. _extract_classes() enhancements (Java): - @RegisterRestClient(configKey="...") → extra["rest_client_config_key"] - Class-level @Path("...") → extra["http_path"] _extract_functions() enhancements (Java): - @GET/@POST/... → extra["http_method"] - Method-level @Path("...") → extra["http_path"] - @ConfigProperty(name="...") → extra["config_properties"] New language "quarkus-config" for application*.yml/yaml files. New language "properties" for .properties files. New method _parse_quarkus_config(): parses application.yml with PyYAML. Emits Config nodes for: - quarkus.rest-client..url (MicroProfile REST clients) - redis.channels list (Redis pub/sub) - kafka.bootstrap.servers - mp.messaging.outgoing/incoming..topic (SmallRye Kafka) - spring.kafka.bootstrap-servers (Spring Boot) - spring.redis.host / app.redis.host - kafka.consumer.topic..name (Spring Boot Kafka consumers) - *-base-url / *-url patterns in custom config blocks New method _parse_properties(): same extractions for .properties format. New edge kinds: CONFIGURES, SUBSCRIBES, PRODUCES, CONSUMES. New node kind: Config. --- code_review_graph/parser.py | 488 ++++++++++++++++++++++++++++++++++++ 1 file changed, 488 insertions(+) diff --git a/code_review_graph/parser.py b/code_review_graph/parser.py index c55b2e8f..b6871900 100644 --- a/code_review_graph/parser.py +++ b/code_review_graph/parser.py @@ -143,6 +143,7 @@ class EdgeInfo: ".v": "verilog", ".vh": "verilog", ".sql": "sql", + ".properties": "properties", } # Shebang interpreter → language mapping for extension-less Unix scripts. @@ -502,6 +503,11 @@ def _builtin_language_names() -> frozenset[str]: # Kafka consumer annotations (annotation-based pattern) _KAFKA_LISTENER_ANNOTATIONS = frozenset({"KafkaListener", "KafkaHandler"}) +# MicroProfile / JAX-RS REST client annotations +_REST_CLIENT_ANNOTATIONS = frozenset({"RegisterRestClient"}) +_HTTP_METHOD_ANNOTATIONS = frozenset({"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"}) +_REDIS_CHANNEL_ANNOTATIONS = frozenset({"ConfigProperty"}) + # Kafka consumer field types (reactive / imperative) _KAFKA_CONSUMER_TYPES = frozenset({ "KafkaReceiver", @@ -862,6 +868,9 @@ def detect_language(self, path: Path) -> Optional[str]: has no suffix at all. See issue #237. """ suffix = path.suffix.lower() + # Quarkus/Spring Boot config files: application*.yml / application*.yaml + if suffix in (".yml", ".yaml") and path.stem.startswith("application"): + return "quarkus-config" lang = self._extension_map.get(suffix) if lang is not None: return lang @@ -989,6 +998,15 @@ def parse_bytes(self, path: Path, source: bytes) -> tuple[list[NodeInfo], list[E if language == "sql": return self._parse_sql(path, source) + # Java .properties files: extract key=value pairs relevant to microservice + # communication (REST client URLs, Redis channels, Kafka bootstrap servers). + if language == "properties": + return self._parse_properties(path, source) + + # Quarkus/Spring Boot application.yml: extract REST client URLs, Redis channels. + if language == "quarkus-config": + return self._parse_quarkus_config(path, source) + parser = self._get_parser(language) if not parser: return [], [] @@ -2000,6 +2018,399 @@ def enclosing_module(off: int) -> Optional[str]: "create_function", }) + def _parse_quarkus_config( + self, path: Path, source: bytes, + ) -> tuple[list[NodeInfo], list[EdgeInfo]]: + """Parse application.yml Quarkus/Spring Boot config files. + + Extracts: + - ``quarkus.rest-client..url`` → Config node + CONFIGURES edge + - ``redis.channels`` → Config node + SUBSCRIBES edges per channel + - ``kafka.bootstrap.servers`` → Config node + """ + try: + import yaml # PyYAML — available in the crg venv + except ImportError: + return [], [] + + file_path_str = str(path) + nodes: list[NodeInfo] = [] + edges: list[EdgeInfo] = [] + + nodes.append(NodeInfo( + kind="File", + name=file_path_str, + file_path=file_path_str, + line_start=1, + line_end=source.count(b"\n") + 1, + language="quarkus-config", + )) + + try: + data = yaml.safe_load(source.decode("utf-8", errors="replace")) or {} + except Exception: + return nodes, edges + + if not isinstance(data, dict): + return nodes, edges + + # --- Quarkus REST clients: quarkus.rest-client..url --- + quarkus = data.get("quarkus", {}) or {} + rest_client_block = quarkus.get("rest-client", {}) or {} + for config_key, client_cfg in rest_client_block.items(): + if not isinstance(client_cfg, dict): + continue + url = client_cfg.get("url") + if not url: + continue + node_name = f"rest-client:{config_key}" + nodes.append(NodeInfo( + kind="Config", + name=node_name, + file_path=file_path_str, + line_start=1, + line_end=1, + language="quarkus-config", + extra={ + "config_type": "rest_client_url", + "config_key": config_key, + "url": str(url), + }, + )) + edges.append(EdgeInfo( + kind="CONFIGURES", + source=file_path_str, + target=node_name, + file_path=file_path_str, + line=1, + extra={"config_key": config_key, "url": str(url)}, + )) + + # --- Redis channels --- + redis_block = data.get("redis", {}) or {} + channels_val = redis_block.get("channels") + if channels_val: + if isinstance(channels_val, list): + channels = [str(c).strip() for c in channels_val if c] + else: + channels = [c.strip() for c in str(channels_val).split(",") if c.strip()] + if channels: + node_name = "redis:channels" + nodes.append(NodeInfo( + kind="Config", + name=node_name, + file_path=file_path_str, + line_start=1, + line_end=1, + language="quarkus-config", + extra={"config_type": "redis_channels", "channels": channels}, + )) + for channel in channels: + edges.append(EdgeInfo( + kind="SUBSCRIBES", + source=file_path_str, + target=f"redis:{channel}", + file_path=file_path_str, + line=1, + extra={"channel": channel}, + )) + + # --- Kafka bootstrap --- + kafka_block = data.get("kafka", {}) or {} + bootstrap = kafka_block.get("bootstrap", {}) or {} + servers = bootstrap.get("servers") if isinstance(bootstrap, dict) else None + if servers: + nodes.append(NodeInfo( + kind="Config", + name="kafka:bootstrap", + file_path=file_path_str, + line_start=1, + line_end=1, + language="quarkus-config", + extra={"config_type": "kafka_bootstrap", "servers": str(servers)}, + )) + + # --- Spring Boot support --- + spring_block = data.get("spring", {}) or {} + if spring_block: + # spring.kafka.bootstrap-servers + spring_kafka = spring_block.get("kafka", {}) or {} + spring_bootstrap = spring_kafka.get("bootstrap-servers") + if spring_bootstrap: + nodes.append(NodeInfo( + kind="Config", + name="kafka:bootstrap", + file_path=file_path_str, + line_start=1, + line_end=1, + language="quarkus-config", + extra={"config_type": "kafka_bootstrap", "servers": str(spring_bootstrap)}, + )) + + # spring.redis.host (Spring Boot 2.x) or spring.data.redis.host (3.x) + spring_redis = spring_block.get("redis", {}) or spring_block.get("data", {}).get("redis", {}) or {} + redis_host = spring_redis.get("host") + if redis_host: + nodes.append(NodeInfo( + kind="Config", + name="redis:host", + file_path=file_path_str, + line_start=1, + line_end=1, + language="quarkus-config", + extra={"config_type": "redis_host", "host": str(redis_host)}, + )) + + # app.redis.host (Spring Boot custom redis config) + app_block = data.get("app", {}) or {} + app_redis = app_block.get("redis", {}) or {} + app_redis_host = app_redis.get("host") + if app_redis_host: + nodes.append(NodeInfo( + kind="Config", + name="redis:host", + file_path=file_path_str, + line_start=1, + line_end=1, + language="quarkus-config", + extra={"config_type": "redis_host", "host": str(app_redis_host)}, + )) + + # Spring Boot custom Kafka topics: kafka.consumer.topic..name (recursive walk) + kafka_custom = data.get("kafka", {}) or {} + consumer_block = kafka_custom.get("consumer", {}) or {} + topic_block = consumer_block.get("topic", {}) or {} + + def _extract_spring_kafka_topics(block: dict, path: str = "") -> list[tuple[str, str]]: + """Walk nested dict collecting leaf nodes that have a 'name' key.""" + results = [] + if not isinstance(block, dict): + return results + if "name" in block and isinstance(block["name"], str): + results.append((path, block["name"])) + else: + for k, v in block.items(): + results.extend(_extract_spring_kafka_topics(v, k)) + return results + + for channel_key, topic_name in _extract_spring_kafka_topics(topic_block): + node_name = f"kafka:{topic_name}" + nodes.append(NodeInfo( + kind="Config", + name=node_name, + file_path=file_path_str, + line_start=1, + line_end=1, + language="quarkus-config", + extra={"config_type": "kafka_consumer_topic", "channel": channel_key, "topic": topic_name}, + )) + edges.append(EdgeInfo( + kind="CONSUMES", + source=file_path_str, + target=node_name, + file_path=file_path_str, + line=1, + extra={"channel": channel_key, "topic": topic_name, "direction": "incoming"}, + )) + + # Spring Boot REST client URLs: any top-level block with keys ending in *-base-url or *-url + # Common pattern: core-endpoints.*-base-url, custom-endpoints.*-url, etc. + for block_name, block_val in data.items(): + if not isinstance(block_val, dict): + continue + if block_name in ("quarkus", "spring", "kafka", "mp", "redis", "app", "air-hospital", + "api-manager", "cache", "mapping-configuration", "security", + "management", "logging", "server", "audit"): + continue + for key, val in block_val.items(): + if isinstance(val, str) and (key.endswith("-base-url") or key.endswith("-url")): + config_key = f"{block_name}.{key}" + node_name = f"rest-client:{config_key}" + nodes.append(NodeInfo( + kind="Config", + name=node_name, + file_path=file_path_str, + line_start=1, + line_end=1, + language="quarkus-config", + extra={"config_type": "rest_client_url", "config_key": config_key, "url": val}, + )) + edges.append(EdgeInfo( + kind="CONFIGURES", + source=file_path_str, + target=node_name, + file_path=file_path_str, + line=1, + extra={"config_key": config_key, "url": val}, + )) + + # --- MicroProfile Messaging: mp.messaging.outgoing/incoming..topic --- + mp_block = data.get("mp", {}) or {} + messaging = mp_block.get("messaging", {}) or {} + for direction in ("outgoing", "incoming"): + direction_block = messaging.get(direction, {}) or {} + if not isinstance(direction_block, dict): + continue + for channel_name, channel_cfg in direction_block.items(): + if not isinstance(channel_cfg, dict): + continue + topic = channel_cfg.get("topic") + if not topic: + continue + edge_kind = "PRODUCES" if direction == "outgoing" else "CONSUMES" + node_name = f"kafka:{topic}" + nodes.append(NodeInfo( + kind="Config", + name=node_name, + file_path=file_path_str, + line_start=1, + line_end=1, + language="quarkus-config", + extra={ + "config_type": f"kafka_{direction}", + "channel": channel_name, + "topic": topic, + }, + )) + edges.append(EdgeInfo( + kind=edge_kind, + source=file_path_str, + target=node_name, + file_path=file_path_str, + line=1, + extra={"channel": channel_name, "topic": topic, "direction": direction}, + )) + + return nodes, edges + + def _parse_properties( + self, path: Path, source: bytes, + ) -> tuple[list[NodeInfo], list[EdgeInfo]]: + """Parse Java .properties files, extracting microservice config as Config nodes. + + Captures: + - REST client base URLs: ``quarkus.rest-client..url`` + - Redis channels: ``redis.channels`` + - Kafka bootstrap: ``kafka.bootstrap.servers`` + - Generic MP REST client URLs: ``/mp-rest/url`` + """ + _REST_URL_RE = re.compile( + r"^(?:quarkus\.rest-client\.|%\w+\.quarkus\.rest-client\.)([^.=\s]+)\.url\s*=\s*(.+)$", + re.MULTILINE, + ) + _MP_REST_URL_RE = re.compile( + r"^([^/\s=]+)/mp-rest/url\s*=\s*(.+)$", + re.MULTILINE, + ) + _REDIS_CHANNELS_RE = re.compile( + r"^redis\.channels\s*=\s*(.+)$", + re.MULTILINE, + ) + _KAFKA_BOOTSTRAP_RE = re.compile( + r"^(?:kafka\.bootstrap\.servers|%\w+\.kafka\.bootstrap\.servers)\s*=\s*(.+)$", + re.MULTILINE, + ) + + file_path_str = str(path) + nodes: list[NodeInfo] = [] + edges: list[EdgeInfo] = [] + + try: + text = source.decode("utf-8", errors="replace") + except Exception: + return [], [] + + nodes.append(NodeInfo( + kind="File", + name=file_path_str, + file_path=file_path_str, + line_start=1, + line_end=text.count("\n") + 1, + language="properties", + )) + + lines_list = text.splitlines() + + def _line_of(match_start: int) -> int: + return text[:match_start].count("\n") + 1 + + # REST client URLs (quarkus.rest-client..url and /mp-rest/url) + seen_keys: set[str] = set() + for pattern in (_REST_URL_RE, _MP_REST_URL_RE): + for m in pattern.finditer(text): + config_key = m.group(1).strip() + url = m.group(2).strip() + if config_key in seen_keys: + continue + seen_keys.add(config_key) + node_name = f"rest-client:{config_key}" + nodes.append(NodeInfo( + kind="Config", + name=node_name, + file_path=file_path_str, + line_start=_line_of(m.start()), + line_end=_line_of(m.start()), + language="properties", + extra={ + "config_type": "rest_client_url", + "config_key": config_key, + "url": url, + }, + )) + edges.append(EdgeInfo( + kind="CONFIGURES", + source=file_path_str, + target=node_name, + file_path=file_path_str, + line=_line_of(m.start()), + extra={"config_key": config_key, "url": url}, + )) + + # Redis channels + for m in _REDIS_CHANNELS_RE.finditer(text): + channels_raw = m.group(1).strip() + channels = [c.strip() for c in channels_raw.split(",") if c.strip()] + node_name = "redis:channels" + nodes.append(NodeInfo( + kind="Config", + name=node_name, + file_path=file_path_str, + line_start=_line_of(m.start()), + line_end=_line_of(m.start()), + language="properties", + extra={ + "config_type": "redis_channels", + "channels": channels, + }, + )) + for channel in channels: + edges.append(EdgeInfo( + kind="SUBSCRIBES", + source=file_path_str, + target=f"redis:{channel}", + file_path=file_path_str, + line=_line_of(m.start()), + extra={"channel": channel}, + )) + + # Kafka bootstrap + for m in _KAFKA_BOOTSTRAP_RE.finditer(text): + bootstrap = m.group(1).strip() + nodes.append(NodeInfo( + kind="Config", + name="kafka:bootstrap", + file_path=file_path_str, + line_start=_line_of(m.start()), + line_end=_line_of(m.start()), + language="properties", + extra={ + "config_type": "kafka_bootstrap", + "servers": bootstrap, + }, + )) + + return nodes, edges + def _parse_sql( self, path: Path, source: bytes, ) -> tuple[list[NodeInfo], list[EdgeInfo]]: @@ -4080,6 +4491,34 @@ def _emit_temporal_stub_fields( )}, )) + @staticmethod + @staticmethod + def _get_annotation_arg(annotation_node, key: str) -> Optional[str]: + """Extract a single string argument value from an annotation by key name. + + Handles both ``@Ann(key = "value")`` and ``@Ann("value")`` (single-value + shorthand where key defaults to ``value``). + """ + for child in annotation_node.children: + if child.type != "annotation_argument_list": + continue + for pair in child.children: + if pair.type == "element_value_pair": + key_node = next( + (c for c in pair.children if c.type == "identifier"), None + ) + if key_node is None: + continue + if key_node.text.decode("utf-8", errors="replace") != key: + continue + for val in pair.children: + if val.type == "string_literal": + return val.text.decode("utf-8", errors="replace").strip('"').strip("'") + elif pair.type == "string_literal" and key == "value": + # Single-value shorthand: @Ann("somevalue") + return pair.text.decode("utf-8", errors="replace").strip('"').strip("'") + return None + @staticmethod def _get_kafka_annotation_topics(annotation_node) -> list[str]: """Extract topic strings from @KafkaListener(topics = "...") or topics = {"a","b"}.""" @@ -4296,6 +4735,30 @@ def _extract_classes( role = "workflow_interface" if is_wf else "activity_interface" extra["temporal_role"] = role + # MicroProfile REST Client: extract configKey and class-level @Path + if any(a in _REST_CLIENT_ANNOTATIONS for a in class_annotations): + extra["rest_client"] = True + for mod_child in child.children: + if mod_child.type != "modifiers": + continue + for ann in mod_child.children: + if ann.type not in ("annotation", "marker_annotation"): + continue + ann_name_node = next( + (s for s in ann.children if s.type == "identifier"), None + ) + if ann_name_node is None: + continue + ann_name = ann_name_node.text.decode("utf-8", errors="replace") + if ann_name == "RegisterRestClient": + config_key = self._get_annotation_arg(ann, "configKey") + if config_key: + extra["rest_client_config_key"] = config_key + elif ann_name == "Path": + path_val = self._get_annotation_arg(ann, "value") + if path_val: + extra["http_path"] = path_val + node = NodeInfo( kind="Class", name=name, @@ -4445,6 +4908,31 @@ def _extract_functions( child, name, enclosing_class, file_path, edges, ) + # Java: extract JAX-RS / MicroProfile REST method annotations (@GET, @Path, etc.) + if language == "java": + for sub in child.children: + if sub.type != "modifiers": + continue + for ann in sub.children: + if ann.type not in ("annotation", "marker_annotation"): + continue + ann_name_node = next( + (s for s in ann.children if s.type == "identifier"), None + ) + if ann_name_node is None: + continue + ann_name = ann_name_node.text.decode("utf-8", errors="replace") + if ann_name in _HTTP_METHOD_ANNOTATIONS: + method_extra["http_method"] = ann_name + elif ann_name == "Path": + path_val = self._get_annotation_arg(ann, "value") + if path_val: + method_extra["http_path"] = path_val + elif ann_name == "ConfigProperty": + prop_name = self._get_annotation_arg(ann, "name") + if prop_name: + method_extra.setdefault("config_properties", []).append(prop_name) + node = NodeInfo( kind=kind, name=name, From ba0880c22e13137c8ea7d859713936cc33f21617 Mon Sep 17 00:00:00 2001 From: JoseJacin Date: Wed, 24 Jun 2026 15:31:12 +0200 Subject: [PATCH 2/2] test(parser): add tests for Java microservice config extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests for TestJavaMicroserviceAnnotations: - @RegisterRestClient(configKey) stored in extra["rest_client_config_key"] - Class-level @Path stored in extra["http_path"] - @GET/@POST/@DELETE stored in extra["http_method"] - Method-level @Path stored in extra["http_path"] - Non-REST-client classes not marked - Methods without HTTP annotations have no http_method Tests for TestQuarkusConfigParser: - application.yml / application.yaml / application-local.yml → quarkus-config - .properties → properties language - Non-application .yml → None (not picked up) - quarkus.rest-client..url → Config node with correct name and extra - Multiple REST clients → one Config node each - redis.channels list → Config node with channels list - kafka.bootstrap.servers → kafka:bootstrap Config node - mp.messaging.outgoing..topic → Config node (kafka_outgoing) - mp.messaging.incoming..topic → Config node (kafka_incoming) - spring.kafka.bootstrap-servers → kafka:bootstrap Config node - Empty application.yml → only File node emitted --- tests/test_parser.py | 264 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 264 insertions(+) diff --git a/tests/test_parser.py b/tests/test_parser.py index d1d96411..a5d9c100 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1458,3 +1458,267 @@ def test_scoped_function_string_return(self, tmp_path): fns = [n for n in nodes if n.kind == "Function"] assert len(fns) == 1 assert fns[0].name == "get_obj_fingerprint" + + +# --------------------------------------------------------------------------- +# Java microservice config extraction +# --------------------------------------------------------------------------- + + +class TestJavaMicroserviceAnnotations: + """Tests for @RegisterRestClient, @Path, and HTTP method annotation extraction.""" + + def setup_method(self): + self.parser = CodeParser() + + def test_register_rest_client_config_key(self): + """@RegisterRestClient(configKey=...) is stored in extra['rest_client_config_key'].""" + nodes, _ = self.parser.parse_bytes( + Path("/src/MyClient.java"), + b"import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;\n" + b"import jakarta.ws.rs.Path;\n" + b"@RegisterRestClient(configKey = \"my-service-api\")\n" + b"@Path(\"/api/v1\")\n" + b"public interface MyClient {\n" + b"}\n", + ) + classes = [n for n in nodes if n.kind == "Class"] + assert len(classes) == 1 + assert classes[0].extra.get("rest_client") is True + assert classes[0].extra.get("rest_client_config_key") == "my-service-api" + + def test_register_rest_client_class_path(self): + """@Path on a @RegisterRestClient interface is stored in extra['http_path'].""" + nodes, _ = self.parser.parse_bytes( + Path("/src/MyClient.java"), + b"@RegisterRestClient(configKey = \"svc-api\")\n" + b"@Path(\"/api/v1/resource\")\n" + b"public interface MyClient {\n" + b"}\n", + ) + classes = [n for n in nodes if n.kind == "Class"] + assert classes[0].extra.get("http_path") == "/api/v1/resource" + + def test_http_method_get_on_interface_method(self): + """@GET on an interface method is stored in extra['http_method'].""" + nodes, _ = self.parser.parse_bytes( + Path("/src/MyClient.java"), + b"@RegisterRestClient(configKey = \"svc-api\")\n" + b"public interface MyClient {\n" + b" @GET\n" + b" @Path(\"/items\")\n" + b" List getItems();\n" + b"}\n", + ) + fns = [n for n in nodes if n.kind == "Function"] + assert len(fns) == 1 + assert fns[0].extra.get("http_method") == "GET" + assert fns[0].extra.get("http_path") == "/items" + + def test_http_method_post_on_interface_method(self): + """@POST on an interface method is stored in extra['http_method'].""" + nodes, _ = self.parser.parse_bytes( + Path("/src/MyClient.java"), + b"@RegisterRestClient(configKey = \"svc-api\")\n" + b"public interface MyClient {\n" + b" @POST\n" + b" @Path(\"/items\")\n" + b" Item createItem(Item item);\n" + b"}\n", + ) + fns = [n for n in nodes if n.kind == "Function"] + assert fns[0].extra.get("http_method") == "POST" + + def test_http_method_delete_on_interface_method(self): + """@DELETE on an interface method is stored in extra['http_method'].""" + nodes, _ = self.parser.parse_bytes( + Path("/src/MyClient.java"), + b"@RegisterRestClient(configKey = \"svc-api\")\n" + b"public interface MyClient {\n" + b" @DELETE\n" + b" @Path(\"/items/{id}\")\n" + b" void deleteItem(String id);\n" + b"}\n", + ) + fns = [n for n in nodes if n.kind == "Function"] + assert fns[0].extra.get("http_method") == "DELETE" + + def test_non_rest_client_class_not_marked(self): + """Classes without @RegisterRestClient do not get extra['rest_client'].""" + nodes, _ = self.parser.parse_bytes( + Path("/src/MyService.java"), + b"public class MyService {\n" + b" public void doWork() {}\n" + b"}\n", + ) + classes = [n for n in nodes if n.kind == "Class"] + assert classes[0].extra.get("rest_client") is None + + def test_method_without_http_annotation_has_no_http_method(self): + """Methods without HTTP annotations do not get extra['http_method'].""" + nodes, _ = self.parser.parse_bytes( + Path("/src/MyService.java"), + b"public class MyService {\n" + b" public String helper() { return null; }\n" + b"}\n", + ) + fns = [n for n in nodes if n.kind == "Function"] + assert fns[0].extra.get("http_method") is None + + +class TestQuarkusConfigParser: + """Tests for application.yml / .properties config file parsing.""" + + def setup_method(self): + self.parser = CodeParser() + + def test_detect_language_application_yml(self, tmp_path): + """application.yml is detected as quarkus-config.""" + p = tmp_path / "application.yml" + p.write_text("quarkus:\n http:\n port: 8080\n") + assert self.parser.detect_language(p) == "quarkus-config" + + def test_detect_language_application_yaml(self, tmp_path): + """application.yaml is detected as quarkus-config.""" + p = tmp_path / "application.yaml" + p.write_text("quarkus:\n http:\n port: 8080\n") + assert self.parser.detect_language(p) == "quarkus-config" + + def test_detect_language_application_local_yml(self, tmp_path): + """application-local.yml is detected as quarkus-config.""" + p = tmp_path / "application-local.yml" + p.write_text("quarkus:\n http:\n port: 8080\n") + assert self.parser.detect_language(p) == "quarkus-config" + + def test_detect_language_properties(self): + """*.properties is detected as properties language.""" + assert self.parser.detect_language(Path("application.properties")) == "properties" + + def test_detect_language_non_application_yml_is_none(self, tmp_path): + """A .yml file not named application*.yml is not detected as quarkus-config.""" + p = tmp_path / "docker-compose.yml" + p.write_text("version: '3'\n") + assert self.parser.detect_language(p) is None + + def test_quarkus_rest_client_url_emits_config_node(self, tmp_path): + """quarkus.rest-client..url emits a Config node.""" + p = tmp_path / "application.yml" + p.write_text( + "quarkus:\n" + " rest-client:\n" + " my-service-api:\n" + " url: https://api.example.com/my-service/api\n" + ) + nodes, _ = self.parser.parse_bytes(p, p.read_bytes()) + config_nodes = [n for n in nodes if n.kind == "Config"] + names = {n.name for n in config_nodes} + assert "rest-client:my-service-api" in names + node = next(n for n in config_nodes if n.name == "rest-client:my-service-api") + assert node.extra["config_key"] == "my-service-api" + assert node.extra["url"] == "https://api.example.com/my-service/api" + + def test_quarkus_multiple_rest_clients(self, tmp_path): + """Multiple quarkus.rest-client entries each emit a Config node.""" + p = tmp_path / "application.yml" + p.write_text( + "quarkus:\n" + " rest-client:\n" + " service-a-api:\n" + " url: https://api.example.com/a\n" + " service-b-api:\n" + " url: https://api.example.com/b\n" + ) + nodes, _ = self.parser.parse_bytes(p, p.read_bytes()) + config_nodes = [n for n in nodes if n.kind == "Config"] + names = {n.name for n in config_nodes} + assert "rest-client:service-a-api" in names + assert "rest-client:service-b-api" in names + + def test_redis_channels_emits_config_node(self, tmp_path): + """redis.channels list emits a Config node with channels list.""" + p = tmp_path / "application.yml" + p.write_text( + "redis:\n" + " channels:\n" + " - allergy_channel\n" + " - medication_channel\n" + ) + nodes, _ = self.parser.parse_bytes(p, p.read_bytes()) + config_nodes = [n for n in nodes if n.kind == "Config"] + names = {n.name for n in config_nodes} + assert "redis:channels" in names + node = next(n for n in config_nodes if n.name == "redis:channels") + assert "allergy_channel" in node.extra["channels"] + assert "medication_channel" in node.extra["channels"] + + def test_kafka_bootstrap_emits_config_node(self, tmp_path): + """kafka.bootstrap.servers emits a Config node.""" + p = tmp_path / "application.yml" + p.write_text( + "kafka:\n" + " bootstrap:\n" + " servers: localhost:9092\n" + ) + nodes, _ = self.parser.parse_bytes(p, p.read_bytes()) + config_nodes = [n for n in nodes if n.kind == "Config"] + names = {n.name for n in config_nodes} + assert "kafka:bootstrap" in names + + def test_mp_messaging_outgoing_topic_emits_config_node(self, tmp_path): + """mp.messaging.outgoing..topic emits a Config node.""" + p = tmp_path / "application.yml" + p.write_text( + "mp:\n" + " messaging:\n" + " outgoing:\n" + " stock-producer:\n" + " connector: smallrye-kafka\n" + " topic: product\n" + ) + nodes, _ = self.parser.parse_bytes(p, p.read_bytes()) + config_nodes = [n for n in nodes if n.kind == "Config"] + names = {n.name for n in config_nodes} + assert "kafka:product" in names + node = next(n for n in config_nodes if n.name == "kafka:product") + assert node.extra["config_type"] == "kafka_outgoing" + assert node.extra["topic"] == "product" + assert node.extra["channel"] == "stock-producer" + + def test_mp_messaging_incoming_topic_emits_config_node(self, tmp_path): + """mp.messaging.incoming..topic emits a Config node.""" + p = tmp_path / "application.yml" + p.write_text( + "mp:\n" + " messaging:\n" + " incoming:\n" + " order-consumer:\n" + " connector: smallrye-kafka\n" + " topic: orders\n" + ) + nodes, _ = self.parser.parse_bytes(p, p.read_bytes()) + config_nodes = [n for n in nodes if n.kind == "Config"] + names = {n.name for n in config_nodes} + assert "kafka:orders" in names + node = next(n for n in config_nodes if n.name == "kafka:orders") + assert node.extra["config_type"] == "kafka_incoming" + + def test_spring_boot_kafka_bootstrap_emits_config_node(self, tmp_path): + """spring.kafka.bootstrap-servers emits a Config node.""" + p = tmp_path / "application.yml" + p.write_text( + "spring:\n" + " kafka:\n" + " bootstrap-servers: localhost:9092\n" + ) + nodes, _ = self.parser.parse_bytes(p, p.read_bytes()) + config_nodes = [n for n in nodes if n.kind == "Config"] + names = {n.name for n in config_nodes} + assert "kafka:bootstrap" in names + + def test_empty_yml_emits_only_file_node(self, tmp_path): + """An empty application.yml emits only the File node.""" + p = tmp_path / "application.yml" + p.write_text("") + nodes, edges = self.parser.parse_bytes(p, p.read_bytes()) + assert len([n for n in nodes if n.kind == "Config"]) == 0 + assert len([n for n in nodes if n.kind == "File"]) == 1