Skip to content

Commit a90c2e5

Browse files
committed
Merge pull request #1 from acsone/16.0-fastapi-multislash-route-lmi
[IMP] fastapi: Ensures endpoint matching is based on path parts
2 parents e5b9865 + c031fae commit a90c2e5

5 files changed

Lines changed: 96 additions & 50 deletions

File tree

fastapi/demo/fastapi_endpoint_demo.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ methods. See documentation to learn more about how to create a new app.
5151
Like the other demo endpoint but with multi-slash
5252
</field>
5353
<field name="app">demo</field>
54-
<field name="root_path">/fastapi/demo</field>
54+
<field name="root_path">/fastapi/demo-multi</field>
5555
<field name="demo_auth_method">http_basic</field>
5656
<field name="user_id" ref="my_demo_app_user" />
5757
</record>

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/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
11
from . import test_fastapi
22
from . import test_fastapi_demo
3-
from . import test_fastapi_multislash_demo

fastapi/tests/test_fastapi.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ class FastAPIHttpCase(HttpCase):
2020
def setUpClass(cls):
2121
super().setUpClass()
2222
cls.fastapi_demo_app = cls.env.ref("fastapi.fastapi_endpoint_demo")
23-
cls.fastapi_demo_app._handle_registry_sync()
23+
cls.fastapi_multi_demo_app = cls.env.ref(
24+
"fastapi.fastapi_endpoint_multislash_demo"
25+
)
26+
cls.fastapi_apps = cls.fastapi_demo_app + cls.fastapi_multi_demo_app
27+
cls.fastapi_apps._handle_registry_sync()
2428
lang = (
2529
cls.env["res.lang"]
2630
.with_context(active_test=False)
@@ -169,3 +173,29 @@ def test_no_commit_on_exception(self) -> None:
169173
expected_message="test",
170174
expected_status_code=status.HTTP_409_CONFLICT,
171175
)
176+
177+
def test_url_matching(self):
178+
# Test the URL mathing method on the endpoint
179+
paths = ["/fastapi", "/fastapi_demo", "/fastapi/v1"]
180+
EndPoint = self.env["fastapi.endpoint"]
181+
self.assertEqual(
182+
EndPoint._find_first_matching_url_path(paths, "/fastapi_demo/test"),
183+
"/fastapi_demo",
184+
)
185+
self.assertEqual(
186+
EndPoint._find_first_matching_url_path(paths, "/fastapi/test"), "/fastapi"
187+
)
188+
self.assertEqual(
189+
EndPoint._find_first_matching_url_path(paths, "/fastapi/v2/test"),
190+
"/fastapi",
191+
)
192+
self.assertEqual(
193+
EndPoint._find_first_matching_url_path(paths, "/fastapi/v1/test"),
194+
"/fastapi/v1",
195+
)
196+
197+
def test_multi_slash(self):
198+
route = "/fastapi/demo-multi/demo/"
199+
response = self.url_open(route, timeout=20)
200+
self.assertEqual(response.status_code, 200)
201+
self.assertIn(self.fastapi_multi_demo_app.root_path, str(response.url))

fastapi/tests/test_fastapi_multislash_demo.py

Lines changed: 0 additions & 32 deletions
This file was deleted.

0 commit comments

Comments
 (0)