|
| 1 | +"""Route-decorator parsing and URL/template matching (issue #425 phase 3). |
| 2 | +
|
| 3 | +Handler decorators are stored verbatim on Function/Method nodes (e.g. |
| 4 | +``@app.get("/users/{id}")``); this module turns them into |
| 5 | +``(METHOD, /path/template)`` pairs and matches literal client URLs against |
| 6 | +those templates so cross-project request edges can resolve to handlers. |
| 7 | +""" |
| 8 | + |
| 9 | +from __future__ import annotations |
| 10 | + |
| 11 | +import re |
| 12 | +from typing import TYPE_CHECKING, cast |
| 13 | +from urllib.parse import urlparse |
| 14 | + |
| 15 | +from .. import constants as cs |
| 16 | + |
| 17 | +if TYPE_CHECKING: |
| 18 | + from ..services import IngestorProtocol, QueryProtocol |
| 19 | + |
| 20 | +# Anchoring on live sink/EXPOSES edges keeps resources whose caller or |
| 21 | +# handler was deleted from relinking; delete-then-relink makes the pass |
| 22 | +# idempotent so changed URLs or routes drop their stale RESOLVES_TO edges. |
| 23 | +CYPHER_LIVE_NETWORK_RESOURCES = ( |
| 24 | + "MATCH ()-[:READS_FROM|WRITES_TO]->(r:Resource {kind: 'NETWORK'}) " |
| 25 | + "RETURN DISTINCT r.qualified_name AS qualified_name, " |
| 26 | + "r.name AS name, r.kind AS kind" |
| 27 | +) |
| 28 | +CYPHER_LIVE_ENDPOINT_RESOURCES = ( |
| 29 | + "MATCH ()-[:EXPOSES]->(r:Resource {kind: 'ENDPOINT'}) " |
| 30 | + "RETURN DISTINCT r.qualified_name AS qualified_name, " |
| 31 | + "r.name AS name, r.kind AS kind" |
| 32 | +) |
| 33 | +CYPHER_DELETE_RESOLVES_TO = "MATCH ()-[r:RESOLVES_TO]->() DELETE r" |
| 34 | + |
| 35 | +_HTTP_METHOD_NAMES = frozenset( |
| 36 | + {"get", "post", "put", "patch", "delete", "head", "options", "websocket"} |
| 37 | +) |
| 38 | +_ROUTE_NAME = "route" |
| 39 | +_DEFAULT_ROUTE_METHOD = "GET" |
| 40 | + |
| 41 | +# ponytail: decorators arrive as raw text, so this is a text parse, not an |
| 42 | +# AST walk. The literal must open the argument list (a leading quote), which |
| 43 | +# rejects computed paths like (prefix + "/users"). |
| 44 | +_DECORATOR_CALL_RE = re.compile( |
| 45 | + r"^@(?:\w+\.)*(?P<name>\w+)\(\s*(?P<quote>['\"])(?P<path>/[^'\"]*)(?P=quote)", |
| 46 | +) |
| 47 | +_METHODS_KWARG_RE = re.compile(r"methods\s*=\s*[\[({](?P<items>[^\])}]*)[\])}]") |
| 48 | +_METHOD_ITEM_RE = re.compile(r"['\"](\w+)['\"]") |
| 49 | + |
| 50 | + |
| 51 | +def parse_route_decorator(decorator_text: str) -> list[tuple[str, str]]: |
| 52 | + """Return ``(METHOD, path_template)`` pairs for a route decorator. |
| 53 | +
|
| 54 | + Non-route decorators, computed paths, and pathless calls yield ``[]``. |
| 55 | + """ |
| 56 | + match = _DECORATOR_CALL_RE.match(decorator_text.strip()) |
| 57 | + if match is None: |
| 58 | + return [] |
| 59 | + name = match.group("name").lower() |
| 60 | + path = match.group("path") |
| 61 | + if name in _HTTP_METHOD_NAMES: |
| 62 | + return [(name.upper(), path)] |
| 63 | + if name != _ROUTE_NAME: |
| 64 | + return [] |
| 65 | + methods_match = _METHODS_KWARG_RE.search(decorator_text) |
| 66 | + if methods_match is None: |
| 67 | + return [(_DEFAULT_ROUTE_METHOD, path)] |
| 68 | + methods = _METHOD_ITEM_RE.findall(methods_match.group("items")) |
| 69 | + return [(m.upper(), path) for m in methods] |
| 70 | + |
| 71 | + |
| 72 | +# FastAPI-style {id} and Flask-style <user_id> / <int:user_id> variables. |
| 73 | +_TEMPLATE_PARAM_RE = re.compile(r"^(\{[^/]+\}|<[^/]+>)$") |
| 74 | + |
| 75 | + |
| 76 | +def url_matches_template(url: str, template: str) -> bool: |
| 77 | + """Match a literal request URL's path against a route template. |
| 78 | +
|
| 79 | + Template segments like ``{id}`` match exactly one path segment; the |
| 80 | + comparison ignores scheme, host, port, query, and a trailing slash. |
| 81 | + """ |
| 82 | + parsed = urlparse(url) |
| 83 | + if not parsed.scheme or not parsed.netloc: |
| 84 | + return False |
| 85 | + url_segments = [s for s in parsed.path.split("/") if s] |
| 86 | + template_segments = [s for s in template.split("/") if s] |
| 87 | + if len(url_segments) != len(template_segments): |
| 88 | + return False |
| 89 | + return all( |
| 90 | + _TEMPLATE_PARAM_RE.match(expected) or expected == actual |
| 91 | + for actual, expected in zip(url_segments, template_segments, strict=True) |
| 92 | + ) |
| 93 | + |
| 94 | + |
| 95 | +def emit_endpoints( |
| 96 | + ingestor: IngestorProtocol, |
| 97 | + label: cs.NodeLabel, |
| 98 | + qualified_name: str, |
| 99 | + decorators: object, |
| 100 | +) -> None: |
| 101 | + """Emit an ENDPOINT Resource plus an EXPOSES edge per route decorator.""" |
| 102 | + # Imported lazily: parsers.utils imports this module, and the io_access |
| 103 | + # package init pulls extract, which imports parsers.utils back. |
| 104 | + from .io_access.constants import KEY_KIND, RESOURCE_QN_FORMAT, ResourceKind |
| 105 | + |
| 106 | + # A filtering sink that would drop the EXPOSES edge must not receive the |
| 107 | + # Resource node either, or selective capture leaves an orphaned endpoint. |
| 108 | + rel_gate = getattr(ingestor, "rel_enabled", None) |
| 109 | + if callable(rel_gate) and not rel_gate(cs.RelationshipType.EXPOSES): |
| 110 | + return |
| 111 | + if not isinstance(decorators, list): |
| 112 | + return |
| 113 | + for decorator in decorators: |
| 114 | + if not isinstance(decorator, str): |
| 115 | + continue |
| 116 | + for method, path in parse_route_decorator(decorator): |
| 117 | + identity = f"{method} {path}" |
| 118 | + resource_qn = RESOURCE_QN_FORMAT.format( |
| 119 | + kind=ResourceKind.ENDPOINT.value, identity=identity |
| 120 | + ) |
| 121 | + ingestor.ensure_node_batch( |
| 122 | + cs.NodeLabel.RESOURCE, |
| 123 | + { |
| 124 | + cs.KEY_QUALIFIED_NAME: resource_qn, |
| 125 | + cs.KEY_NAME: identity, |
| 126 | + KEY_KIND: ResourceKind.ENDPOINT.value, |
| 127 | + }, |
| 128 | + ) |
| 129 | + ingestor.ensure_relationship_batch( |
| 130 | + (label, cs.KEY_QUALIFIED_NAME, qualified_name), |
| 131 | + cs.RelationshipType.EXPOSES, |
| 132 | + (cs.NodeLabel.RESOURCE, cs.KEY_QUALIFIED_NAME, resource_qn), |
| 133 | + ) |
| 134 | + |
| 135 | + |
| 136 | +def _collect_live_resources( |
| 137 | + ingestor: QueryProtocol, |
| 138 | +) -> tuple[dict[str, str], dict[str, str]]: |
| 139 | + from .io_access.constants import DYNAMIC_TARGET, KEY_KIND, ResourceKind |
| 140 | + |
| 141 | + networks: dict[str, str] = {} |
| 142 | + endpoints: dict[str, str] = {} |
| 143 | + for query in (CYPHER_LIVE_NETWORK_RESOURCES, CYPHER_LIVE_ENDPOINT_RESOURCES): |
| 144 | + for row in ingestor.fetch_all(query): |
| 145 | + qn = row.get(cs.KEY_QUALIFIED_NAME) |
| 146 | + name = row.get(cs.KEY_NAME) |
| 147 | + if not isinstance(qn, str) or not isinstance(name, str): |
| 148 | + continue |
| 149 | + kind = row.get(KEY_KIND) |
| 150 | + if kind == ResourceKind.NETWORK.value and name != DYNAMIC_TARGET: |
| 151 | + networks[qn] = name |
| 152 | + elif kind == ResourceKind.ENDPOINT.value: |
| 153 | + endpoints[qn] = name |
| 154 | + return networks, endpoints |
| 155 | + |
| 156 | + |
| 157 | +def link_endpoints(ingestor: QueryProtocol) -> int: |
| 158 | + """Resolve literal client request URLs to matching ENDPOINT resources. |
| 159 | +
|
| 160 | + Endpoint identities are ``METHOD /path/template``; a NETWORK resource |
| 161 | + links to every endpoint whose template matches its URL path. Matching |
| 162 | + is method-agnostic: the request method lives on the sink edge, not in |
| 163 | + the Resource identity. Returns the number of edges emitted. |
| 164 | + """ |
| 165 | + ingestor.execute_write(CYPHER_DELETE_RESOLVES_TO) |
| 166 | + networks, endpoints = _collect_live_resources(ingestor) |
| 167 | + |
| 168 | + # The live ingestor both queries and writes; QueryProtocol alone types |
| 169 | + # the read side, so the single write goes through an ingestor view. |
| 170 | + writer = cast("IngestorProtocol", ingestor) |
| 171 | + created = 0 |
| 172 | + for network_qn, url in networks.items(): |
| 173 | + for endpoint_qn, identity in endpoints.items(): |
| 174 | + _, _, template = identity.partition(" ") |
| 175 | + if template and url_matches_template(url, template): |
| 176 | + writer.ensure_relationship_batch( |
| 177 | + (cs.NodeLabel.RESOURCE, cs.KEY_QUALIFIED_NAME, network_qn), |
| 178 | + cs.RelationshipType.RESOLVES_TO, |
| 179 | + (cs.NodeLabel.RESOURCE, cs.KEY_QUALIFIED_NAME, endpoint_qn), |
| 180 | + ) |
| 181 | + created += 1 |
| 182 | + return created |
0 commit comments