Skip to content

Commit 55adbd2

Browse files
lmignonPicchiSeba
authored andcommitted
[IMP] fastapi: Ensures endpoint matching is based on path parts
When we lookup the fastapi.endpoint to use to process a request at a given path, we must ensure that the matching mechanism on the path and the endpoint root_path is bases on the path parts. e.g. /a/b is not prefix of /a/bc. In the same time improves robustness of the matching mechanism and avoid useless SQL queries
1 parent 4926f48 commit 55adbd2

2 files changed

Lines changed: 84 additions & 15 deletions

File tree

fastapi/models/fastapi_endpoint.py

Lines changed: 64 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,8 @@ def _endpoint_registry_route_unique_key(self, routing: Dict[str, Any]):
198198
return f"{self._name}:{self.id}:{path}"
199199

200200
def _reset_app(self):
201+
self._get_id_by_root_path_map.clear_cache(self)
202+
self._get_id_for_path.clear_cache(self)
201203
self._reset_app_cache_marker.clear_cache(self)
202204

203205
@tools.ormcache()
@@ -211,24 +213,71 @@ def _reset_app_cache_marker(self):
211213
"""
212214

213215
@api.model
214-
@tools.ormcache("path")
215-
def get_endpoint(self, path):
216-
# try to match the request url with the most similar endpoint
217-
endpoints_by_length = self.search([]).sorted(
218-
lambda fe: len(fe.root_path), reverse=True
216+
def _normalize_url_path(self, path) -> str:
217+
"""
218+
Normalize a URL path:
219+
* Remove redundant slashes,
220+
* Remove trailing slash (unless it's the root),
221+
* Lowercase for case-insensitive matching
222+
"""
223+
parts = [part.lower() for part in path.strip().split("/") if part]
224+
return "/" + "/".join(parts)
225+
226+
@api.model
227+
def _is_suburl(self, path, prefix) -> bool:
228+
"""
229+
Check if 'path' is a subpath of 'prefix' in URL logic:
230+
* Must start with the prefix followed by a slash
231+
This will ensure that the matching is done one the path
232+
parts and ensures that e.g. /a/b is not prefix of /a/bc.
233+
"""
234+
path = self._normalize_url_path(path)
235+
prefix = self._normalize_url_path(prefix)
236+
237+
if path == prefix:
238+
return True
239+
if path.startswith(prefix + "/"):
240+
return True
241+
return False
242+
243+
@api.model
244+
def _find_first_matching_url_path(self, paths, prefix) -> str | None:
245+
"""
246+
Return the first path that is a subpath of 'prefix',
247+
ordered by longest URL path first (most number of segments).
248+
"""
249+
# Sort by number of segments (shallowest first)
250+
sorted_paths = sorted(
251+
paths,
252+
key=lambda p: len(self._normalize_url_path(p).split("/")),
253+
reverse=True,
219254
)
220-
endpoint = False
221-
while endpoints_by_length:
222-
candidate_endpoint = endpoints_by_length[0]
223-
if path.startswith(candidate_endpoint.root_path):
224-
endpoint = candidate_endpoint
225-
break
226-
endpoints_by_length -= candidate_endpoint
227-
return endpoint
255+
256+
for path in sorted_paths:
257+
if self._is_suburl(prefix, path):
258+
return path
259+
return None
260+
261+
@api.model
262+
@tools.ormcache()
263+
def _get_id_by_root_path_map(self):
264+
return {r.root_path: r.id for r in self.search([])}
265+
266+
@api.model
267+
@tools.ormcache("path")
268+
def _get_id_for_path(self, path):
269+
id_by_path = self._get_id_by_root_path_map()
270+
root_path = self._find_first_matching_url_path(id_by_path.keys(), path)
271+
return id_by_path.get(root_path)
272+
273+
@api.model
274+
def _get_endpoint(self, path):
275+
id_ = self._get_id_for_path(path)
276+
return self.browse(id_) if id_ else None
228277

229278
@api.model
230279
def get_app(self, path):
231-
record = self.get_endpoint(path)
280+
record = self._get_endpoint(path)
232281
if not record:
233282
return None
234283
app = FastAPI()
@@ -254,7 +303,7 @@ def _clear_fastapi_exception_handlers(self, app: FastAPI) -> None:
254303
@api.model
255304
@tools.ormcache("path")
256305
def get_uid(self, path):
257-
record = self.get_endpoint(path)
306+
record = self._get_endpoint(path)
258307
if not record:
259308
return None
260309
return record.user_id.id

fastapi/tests/test_fastapi.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,23 @@ def test_no_commit_on_exception(self) -> None:
169169
expected_message="test",
170170
expected_status_code=status.HTTP_409_CONFLICT,
171171
)
172+
173+
def test_url_matching(self):
174+
# Test the URL mathing method on the endpoint
175+
paths = ["/fastapi", "/fastapi_demo", "/fastapi/v1"]
176+
EndPoint = self.env["fastapi.endpoint"]
177+
self.assertEqual(
178+
EndPoint._find_first_matching_url_path(paths, "/fastapi_demo/test"),
179+
"/fastapi_demo",
180+
)
181+
self.assertEqual(
182+
EndPoint._find_first_matching_url_path(paths, "/fastapi/test"), "/fastapi"
183+
)
184+
self.assertEqual(
185+
EndPoint._find_first_matching_url_path(paths, "/fastapi/v2/test"),
186+
"/fastapi",
187+
)
188+
self.assertEqual(
189+
EndPoint._find_first_matching_url_path(paths, "/fastapi/v1/test"),
190+
"/fastapi/v1",
191+
)

0 commit comments

Comments
 (0)