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

1"""Framework introspection: what path was this, and what route matched it. 

2 

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""" 

10 

11from __future__ import annotations 

12 

13import logging 

14from typing import Any 

15 

16from starlette.routing import Match 

17from starlette.types import Scope 

18 

19logger = logging.getLogger("asgi_profiler") 

20 

21__all__ = ["request_path", "route_path", "route_pattern"] 

22 

23 

24def request_path(scope: Scope) -> str: 

25 """The full path of the request, including any mount or proxy prefix. 

26 

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}" 

41 

42 

43def route_path(scope: Scope) -> str: 

44 """The path *relative to the mount*, which is what routes are declared in. 

45 

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 

56 

57 

58def _pattern_from_router(scope: Scope, root_path: str) -> str: 

59 """Ask the router which route matched, and take that route's own path. 

60 

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. 

65 

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}, "") 

78 

79 

80def _walk_routes(routes: Any, scope: Scope, prefix: str) -> str: 

81 """Mirror the router's own resolution: first full match wins. 

82 

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 

99 

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 

112 

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 

118 

119 

120def _pattern_from_params(scope: Scope) -> str: 

121 """Fallback: rewrite the parameter values out of the concrete path. 

122 

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 "" 

132 

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)) 

147 

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 

155 

156 

157def route_pattern(scope: Scope, root_path: str = "") -> str: 

158 """The matched route pattern, e.g. ``/users/{user_id}``. 

159 

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. 

165 

166 Called after the downstream app returns: the router mutates the scope dict 

167 in place, so by then it is populated. 

168 

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 

180 

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) 

187 

188 found = _pattern_from_router(scope, root_path) 

189 if found: 

190 return root_path.rstrip("/") + found 

191 return _pattern_from_params(scope)