From 4af7d61cd89f717e47a0ec3ffdce57f0d3c3b9f1 Mon Sep 17 00:00:00 2001 From: akinolur <126256260+akinolur@users.noreply.github.com> Date: Tue, 23 Sep 2025 01:14:11 +0200 Subject: [PATCH] fix router prefix collision causing 405 errors Fixes #1546 - merge operations from routers with equivalent normalized prefixes - tests added --- ninja/router.py | 40 +++++- .../test_router_multiple_methods_same_path.py | 114 ++++++++++++++++++ 2 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 tests/test_router_multiple_methods_same_path.py diff --git a/ninja/router.py b/ninja/router.py index c67d33b8b..c9fbd7de7 100644 --- a/ninja/router.py +++ b/ninja/router.py @@ -385,8 +385,9 @@ def urls_paths(self, prefix: str) -> Iterator[URLPattern]: # Ensure decorators are applied before generating URLs self._apply_decorators_to_operations() + all_path_operations = self._get_all_path_operations(prefix) prefix = replace_path_param_notation(prefix) - for path, path_view in self.path_operations.items(): + for path, path_view in all_path_operations.items(): for operation in path_view.operations: path = replace_path_param_notation(path) route = "/".join([i for i in (prefix, path) if i]) @@ -400,6 +401,43 @@ def urls_paths(self, prefix: str) -> Iterator[URLPattern]: yield django_path(route, path_view.get_view(), name=url_name) + def _get_all_path_operations(self, prefix: str) -> Dict[str, Any]: + all_path_operations = dict(self.path_operations) + + if not self.api: + return all_path_operations # pragma: no cover + + current_prefix_norm = normalize_path(prefix).lstrip("/") + + for other_prefix, other_router in self.api._routers: + if other_router == self: + continue + + other_prefix_norm = normalize_path(other_prefix).lstrip("/") + if other_prefix_norm != current_prefix_norm: + continue + + # merge operations from router with same normalized prefix + for path, other_path_view in other_router.path_operations.items(): + if path not in all_path_operations: + all_path_operations[path] = other_path_view + continue + + # merge operations for same path + existing_methods = { + m for op in all_path_operations[path].operations for m in op.methods + } + for operation in other_path_view.operations: + if any(m in existing_methods for m in operation.methods): + continue + + all_path_operations[path].operations.append(operation) + all_path_operations[path].is_async = ( + all_path_operations[path].is_async or operation.is_async + ) + + return all_path_operations + def add_router( self, prefix: str, diff --git a/tests/test_router_multiple_methods_same_path.py b/tests/test_router_multiple_methods_same_path.py new file mode 100644 index 000000000..83c4b326d --- /dev/null +++ b/tests/test_router_multiple_methods_same_path.py @@ -0,0 +1,114 @@ +from ninja import NinjaAPI, Router +from ninja.testing import TestClient + + +def test_multiple_routers_same_path_different_methods(): + api = NinjaAPI() + + router1 = Router() + + @router1.get("/items") + def get_items(request): + return {"method": "GET", "router": 1} + + @router1.post("/items") + def create_item(request): + return {"method": "POST", "router": 1} + + router2 = Router() + + @router2.put("/items") + def update_item(request): + return {"method": "PUT", "router": 2} + + @router2.delete("/items") + def delete_item(request): + return {"method": "DELETE", "router": 2} + + api.add_router("", router1) + api.add_router("", router2) + + client = TestClient(api) + + response = client.get("/items") + assert response.status_code == 200 + assert response.json() == {"method": "GET", "router": 1} + + response = client.post("/items") + assert response.status_code == 200 + assert response.json() == {"method": "POST", "router": 1} + + response = client.put("/items") + assert response.status_code == 200 + assert response.json() == {"method": "PUT", "router": 2} + + response = client.delete("/items") + assert response.status_code == 200 + assert response.json() == {"method": "DELETE", "router": 2} + + # unsupported method returns 405 + response = client.patch("/items") + assert response.status_code == 405 + + +def test_api_and_router_same_path_different_methods(): + api = NinjaAPI() + + @api.get("/users") + def get_users(request): + return {"method": "GET", "source": "api"} + + router = Router() + + @router.put("/users") + def update_user(request): + return {"method": "PUT", "source": "router"} + + @router.delete("/users") + def delete_user(request): + return {"method": "DELETE", "source": "router"} + + api.add_router("", router) + + client = TestClient(api) + + response = client.get("/users") + assert response.status_code == 200 + assert response.json() == {"method": "GET", "source": "api"} + + response = client.put("/users") + assert response.status_code == 200 + assert response.json() == {"method": "PUT", "source": "router"} + + response = client.delete("/users") + assert response.status_code == 200 + assert response.json() == {"method": "DELETE", "source": "router"} + + # unsupported method returns 405 + response = client.post("/users") + assert response.status_code == 405 + + +def test_overlapping_methods_different_routers(): + api = NinjaAPI() + + router1 = Router() + + @router1.get("/overlap") + def get_overlap_1(request): + return {"source": "router1"} + + router2 = Router() + + @router2.get("/overlap") + def get_overlap_2(request): + return {"source": "router2"} + + api.add_router("", router1) + api.add_router("", router2) + + client = TestClient(api) + + # first router's handler should be used + response = client.get("/overlap") + assert response.status_code == 200