Coverage for src/asgi_profiler/routing.py: 82%
80 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-22 16:59 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-22 16:59 +0000
1"""Framework introspection: what path was this, and what route matched it.
3Separate from the middleware on purpose. This is the most fragile code in the
4package -- it reimplements the resolution `starlette.routing.Router.app` does,
5and it reads scope keys whose presence differs between Starlette, FastAPI and
6whatever mounts them. Keeping it here bounds the blast radius of the next
7Starlette release to one file, instead of burying it in the module people open
8to understand request capture.
9"""
11from __future__ import annotations
13import logging
14from typing import Any
16from starlette.routing import Match
17from starlette.types import Scope
19logger = logging.getLogger("asgi_profiler")
21__all__ = ["request_path", "route_path", "route_pattern"]
24def request_path(scope: Scope) -> str:
25 """The full path of the request, including any mount or proxy prefix.
27 ASGI servers and Starlette disagree about whether `scope["path"]` already
28 carries `root_path`. Modern Starlette leaves the full path in `path` and
29 *adds* `root_path`; older servers strip it. Concatenating unconditionally
30 turns `/api/users` into `/api/api/users` behind a mount -- which corrupts
31 every recorded path and, worse, stops the viewer's own mount from being
32 excluded, so the profiler starts profiling its own UI.
33 """
34 path = scope.get("path", "/")
35 root = (scope.get("root_path") or "").rstrip("/")
36 if not root:
37 return path
38 if path == root or path.startswith(root + "/"):
39 return path # already absolute
40 return f"{root}{path}"
43def route_path(scope: Scope) -> str:
44 """The path *relative to the mount*, which is what routes are declared in.
46 `exclude_paths` and `mount_path` are written against the application's own
47 routing table, so they have to be matched against this rather than against
48 the absolute path -- otherwise mounting the whole app under `/api` stops
49 the viewer excluding itself.
50 """
51 path = request_path(scope)
52 root = (scope.get("root_path") or "").rstrip("/")
53 if root and (path == root or path.startswith(root + "/")):
54 return path[len(root) :] or "/"
55 return path
58def _pattern_from_router(scope: Scope, root_path: str) -> str:
59 """Ask the router which route matched, and take that route's own path.
61 Exact, unlike reconstructing from `path_params`: it survives typed
62 converters (`{uid:int}` matching `007`), values that collide with a
63 literal segment (`/users/users`), and nested mounts, none of which the
64 parameter values alone can tell you.
66 `root_path` must be the value from *before* the request was routed. The
67 router mutates the scope in place -- `Mount.matches` appends its prefix to
68 `root_path` -- and `scope["router"]` is always the outermost router. Replay
69 the match against the top-level routes with a mount-widened `root_path` and
70 every route in the table is evaluated against the wrong path, which is how
71 a request to `/api/u/7` comes back labelled with an unrelated top-level
72 `/u/{name}`.
73 """
74 routes = getattr(scope.get("router"), "routes", None)
75 if not routes: 75 ↛ 76line 75 didn't jump to line 76 because the condition on line 75 was never true
76 return ""
77 return _walk_routes(routes, {**scope, "root_path": root_path}, "")
80def _walk_routes(routes: Any, scope: Scope, prefix: str) -> str:
81 """Mirror the router's own resolution: first full match wins.
83 Taking the first match rather than the best one is deliberate -- it is
84 exactly what `Router.app` does, so we name the route that actually ran.
85 """
86 partial = ""
87 for route in routes:
88 try:
89 match, child = route.matches(scope)
90 except Exception: # pragma: no cover - a custom route that raises
91 # Skipping is right -- naming a route must never break the request
92 # being profiled -- but skipping in total silence is how a whole
93 # router quietly reports as unmatched. Say so at debug level; this
94 # costs nothing until it actually happens.
95 logger.debug("route %r raised while matching", route, exc_info=True)
96 continue
97 if match is Match.NONE:
98 continue
100 path = getattr(route, "path", "")
101 nested = getattr(route, "routes", None)
102 if nested: # a Mount or Host: descend with the child scope it hands us
103 found = _walk_routes(nested, {**scope, **child}, prefix + path)
104 if found: 104 ↛ 106line 104 didn't jump to line 106 because the condition on line 104 was always true
105 return found
106 if match is Match.FULL:
107 # Nothing inside matched -- a raw ASGI app behind the mount,
108 # say StaticFiles. The mount path is the most specific pattern
109 # available, and grouping every file under it is what we want.
110 return prefix + path
111 continue
113 if match is Match.FULL: 113 ↛ 115line 113 didn't jump to line 115 because the condition on line 113 was always true
114 return prefix + path if path else ""
115 if not partial and path: # a method mismatch: a 405 still has a route
116 partial = prefix + path
117 return partial
120def _pattern_from_params(scope: Scope) -> str:
121 """Fallback: rewrite the parameter values out of the concrete path.
123 Substitutes whole segments, left to right, consuming each only once -- a
124 plain `str.replace` gets `/2/users/2` wrong and collapses two parameters
125 that happen to share a value. Still approximate: a converted value whose
126 `str()` differs from the text in the URL (`{uid:int}` matching `007`)
127 cannot be located this way, which is why the router is asked first.
128 """
129 params = scope.get("path_params") or {}
130 if not params:
131 return ""
133 segments = request_path(scope).split("/")
134 consumed: set[int] = set()
135 leftover: list[tuple[str, str]] = []
136 for name, value in params.items():
137 text = str(value)
138 if not text: 138 ↛ 139line 138 didn't jump to line 139 because the condition on line 138 was never true
139 continue # an empty value would match the '' before the leading /
140 for index, segment in enumerate(segments): 140 ↛ 146line 140 didn't jump to line 146 because the loop on line 140 didn't complete
141 if index not in consumed and segment == text:
142 segments[index] = f"{{{name}}}"
143 consumed.add(index)
144 break
145 else:
146 leftover.append((name, text))
148 path = "/".join(segments)
149 # A `path:`-style converter matches across segments, so fall back to a
150 # substring rewrite for anything not placed above.
151 for name, text in leftover: 151 ↛ 152line 151 didn't jump to line 152 because the loop on line 151 never started
152 if text in path:
153 path = path.replace(text, f"{{{name}}}", 1)
154 return path
157def route_pattern(scope: Scope, root_path: str = "") -> str:
158 """The matched route pattern, e.g. ``/users/{user_id}``.
160 Frameworks disagree about what they leave behind in the scope. FastAPI
161 sets ``scope["route"]`` with a `path` / `path_format`; **plain Starlette
162 sets no `route` key at all** -- only `endpoint`, `path_params` and the
163 `router` itself. So take the pattern where it is offered, ask the router
164 where it is not, and fall back to reconstructing it from the parameters.
166 Called after the downstream app returns: the router mutates the scope dict
167 in place, so by then it is populated.
169 Whatever the source, the pattern is returned absolute -- prefixed to match
170 `Profile.path`. A route declared inside a mounted sub-app knows itself only
171 as `/dashboard/{tab}`, and two sub-apps mounted at different prefixes will
172 happily declare the same relative path, so grouping on the bare pattern
173 merges unrelated endpoints into one summary row.
174 """
175 route = scope.get("route")
176 pattern = getattr(route, "path_format", None) or getattr(route, "path", None)
177 if isinstance(pattern, str) and pattern:
178 # `root_path` has by now accumulated every mount prefix crossed.
179 return (scope.get("root_path") or "").rstrip("/") + pattern
181 if scope.get("endpoint") is not None and not scope.get("path_params"):
182 # A concrete route with nothing to substitute: the path *is* the
183 # pattern, and skipping the router walk keeps the common case free.
184 # Requires an endpoint -- a raw ASGI mount and a 404 both arrive here
185 # with no parameters, and neither should be taken at face value.
186 return request_path(scope)
188 found = _pattern_from_router(scope, root_path)
189 if found:
190 return root_path.rstrip("/") + found
191 return _pattern_from_params(scope)