diff --git a/fastapi/demo/fastapi_endpoint_demo.xml b/fastapi/demo/fastapi_endpoint_demo.xml
index a1c34e34b..ad3fd9da0 100644
--- a/fastapi/demo/fastapi_endpoint_demo.xml
+++ b/fastapi/demo/fastapi_endpoint_demo.xml
@@ -44,4 +44,15 @@ methods. See documentation to learn more about how to create a new app.
http_basic
+
+
+ Fastapi Multi-Slash Demo Endpoint
+
+ Like the other demo endpoint but with multi-slash
+
+ demo
+ /fastapi/demo-multi
+ http_basic
+
+
diff --git a/fastapi/fastapi_dispatcher.py b/fastapi/fastapi_dispatcher.py
index c41beb2d2..63aedd4f4 100644
--- a/fastapi/fastapi_dispatcher.py
+++ b/fastapi/fastapi_dispatcher.py
@@ -27,11 +27,11 @@ def dispatch(self, endpoint, args):
# don't parse the httprequest let starlette parse the stream
self.request.params = {} # dict(self.request.get_http_params(), **args)
environ = self._get_environ()
- root_path = "/" + environ["PATH_INFO"].split("/")[1]
+ path = environ["PATH_INFO"]
# TODO store the env into contextvar to be used by the odoo_env
# depends method
- with fastapi_app_pool.get_app(env=request.env, root_path=root_path) as app:
- uid = request.env["fastapi.endpoint"].sudo().get_uid(root_path)
+ with fastapi_app_pool.get_app(env=request.env, root_path=path) as app:
+ uid = request.env["fastapi.endpoint"].sudo().get_uid(path)
data = BytesIO()
with self._manage_odoo_env(uid):
for r in app(environ, self._make_response):
diff --git a/fastapi/models/fastapi_endpoint.py b/fastapi/models/fastapi_endpoint.py
index 25e3dc468..93130c69d 100644
--- a/fastapi/models/fastapi_endpoint.py
+++ b/fastapi/models/fastapi_endpoint.py
@@ -198,6 +198,8 @@ def _endpoint_registry_route_unique_key(self, routing: Dict[str, Any]):
return f"{self._name}:{self.id}:{path}"
def _reset_app(self):
+ self._get_id_by_root_path_map.clear_cache(self)
+ self._get_id_for_path.clear_cache(self)
self._reset_app_cache_marker.clear_cache(self)
@tools.ormcache()
@@ -211,8 +213,71 @@ def _reset_app_cache_marker(self):
"""
@api.model
- def get_app(self, root_path):
- record = self.search([("root_path", "=", root_path)])
+ def _normalize_url_path(self, path) -> str:
+ """
+ Normalize a URL path:
+ * Remove redundant slashes,
+ * Remove trailing slash (unless it's the root),
+ * Lowercase for case-insensitive matching
+ """
+ parts = [part.lower() for part in path.strip().split("/") if part]
+ return "/" + "/".join(parts)
+
+ @api.model
+ def _is_suburl(self, path, prefix) -> bool:
+ """
+ Check if 'path' is a subpath of 'prefix' in URL logic:
+ * Must start with the prefix followed by a slash
+ This will ensure that the matching is done one the path
+ parts and ensures that e.g. /a/b is not prefix of /a/bc.
+ """
+ path = self._normalize_url_path(path)
+ prefix = self._normalize_url_path(prefix)
+
+ if path == prefix:
+ return True
+ if path.startswith(prefix + "/"):
+ return True
+ return False
+
+ @api.model
+ def _find_first_matching_url_path(self, paths, prefix) -> str | None:
+ """
+ Return the first path that is a subpath of 'prefix',
+ ordered by longest URL path first (most number of segments).
+ """
+ # Sort by number of segments (shallowest first)
+ sorted_paths = sorted(
+ paths,
+ key=lambda p: len(self._normalize_url_path(p).split("/")),
+ reverse=True,
+ )
+
+ for path in sorted_paths:
+ if self._is_suburl(prefix, path):
+ return path
+ return None
+
+ @api.model
+ @tools.ormcache()
+ def _get_id_by_root_path_map(self):
+ return {r.root_path: r.id for r in self.search([])}
+
+ @api.model
+ @tools.ormcache("path")
+ def _get_id_for_path(self, path):
+ id_by_path = self._get_id_by_root_path_map()
+ root_path = self._find_first_matching_url_path(id_by_path.keys(), path)
+ return id_by_path.get(root_path)
+
+ @api.model
+ def _get_endpoint(self, path):
+ id_ = self._get_id_for_path(path)
+ return self.browse(id_) if id_ else None
+
+ @api.model
+ def get_app(self, path):
+ record = self._get_endpoint(path)
if not record:
return None
app = FastAPI()
@@ -236,9 +301,9 @@ def _clear_fastapi_exception_handlers(self, app: FastAPI) -> None:
self._clear_fastapi_exception_handlers(route.app)
@api.model
- @tools.ormcache("root_path")
- def get_uid(self, root_path):
- record = self.search([("root_path", "=", root_path)])
+ @tools.ormcache("path")
+ def get_uid(self, path):
+ record = self._get_endpoint(path)
if not record:
return None
return record.user_id.id
diff --git a/fastapi/tests/test_fastapi.py b/fastapi/tests/test_fastapi.py
index 37b11a961..fbda932fb 100644
--- a/fastapi/tests/test_fastapi.py
+++ b/fastapi/tests/test_fastapi.py
@@ -20,7 +20,11 @@ class FastAPIHttpCase(HttpCase):
def setUpClass(cls):
super().setUpClass()
cls.fastapi_demo_app = cls.env.ref("fastapi.fastapi_endpoint_demo")
- cls.fastapi_demo_app._handle_registry_sync()
+ cls.fastapi_multi_demo_app = cls.env.ref(
+ "fastapi.fastapi_endpoint_multislash_demo"
+ )
+ cls.fastapi_apps = cls.fastapi_demo_app + cls.fastapi_multi_demo_app
+ cls.fastapi_apps._handle_registry_sync()
lang = (
cls.env["res.lang"]
.with_context(active_test=False)
@@ -169,3 +173,29 @@ def test_no_commit_on_exception(self) -> None:
expected_message="test",
expected_status_code=status.HTTP_409_CONFLICT,
)
+
+ def test_url_matching(self):
+ # Test the URL mathing method on the endpoint
+ paths = ["/fastapi", "/fastapi_demo", "/fastapi/v1"]
+ EndPoint = self.env["fastapi.endpoint"]
+ self.assertEqual(
+ EndPoint._find_first_matching_url_path(paths, "/fastapi_demo/test"),
+ "/fastapi_demo",
+ )
+ self.assertEqual(
+ EndPoint._find_first_matching_url_path(paths, "/fastapi/test"), "/fastapi"
+ )
+ self.assertEqual(
+ EndPoint._find_first_matching_url_path(paths, "/fastapi/v2/test"),
+ "/fastapi",
+ )
+ self.assertEqual(
+ EndPoint._find_first_matching_url_path(paths, "/fastapi/v1/test"),
+ "/fastapi/v1",
+ )
+
+ def test_multi_slash(self):
+ route = "/fastapi/demo-multi/demo/"
+ response = self.url_open(route, timeout=20)
+ self.assertEqual(response.status_code, 200)
+ self.assertIn(self.fastapi_multi_demo_app.root_path, str(response.url))