diff --git a/fasthtml/core.py b/fasthtml/core.py index 58c00f4c..449415b8 100644 --- a/fasthtml/core.py +++ b/fasthtml/core.py @@ -742,12 +742,19 @@ def __repr__(self) -> str: # %% ../nbs/api/00_core.ipynb #e0accf76 @patch def add_route(self:FastHTML, route): - "Add or replace a route in the FastHTML app" + "Add or replace a route, rejecting ambiguous method overlaps on the same path and host" route.methods = [m.upper() for m in listify(route.methods)] - self.router.routes = [r for r in self.router.routes if not - (r.path==route.path and r.name == route.name and - ((route.methods is None) or (set(r.methods) == set(route.methods))))] - self.router.routes.append(route) + meths = set(route.methods) + routes = [] + for r in self.router.routes: + if (r.path==route.path and r.name == route.name and + ((route.methods is None) or (set(r.methods or ()) == meths))): continue + overlap = set(r.methods or ()) & meths + if (r.path == route.path and getattr(r, 'host', None) == getattr(route, 'host', None) + and overlap): + raise ValueError(f"Overlapping route methods {sorted(overlap)!r} for path {route.path!r}") + routes.append(r) + self.router.routes = routes + [route] self.router.routes.sort(key=lambda r: not getattr(r, 'host', None)) diff --git a/nbs/api/00_core.ipynb b/nbs/api/00_core.ipynb index 9238c3a0..050467b3 100644 --- a/nbs/api/00_core.ipynb +++ b/nbs/api/00_core.ipynb @@ -2108,12 +2108,19 @@ "#| export\n", "@patch\n", "def add_route(self:FastHTML, route):\n", - " \"Add or replace a route in the FastHTML app\"\n", + " \"Add or replace a route, rejecting ambiguous method overlaps on the same path and host\"\n", " route.methods = [m.upper() for m in listify(route.methods)]\n", - " self.router.routes = [r for r in self.router.routes if not\n", - " (r.path==route.path and r.name == route.name and\n", - " ((route.methods is None) or (set(r.methods) == set(route.methods))))]\n", - " self.router.routes.append(route)\n", + " meths = set(route.methods)\n", + " routes = []\n", + " for r in self.router.routes:\n", + " if (r.path==route.path and r.name == route.name and\n", + " ((route.methods is None) or (set(r.methods or ()) == meths))): continue\n", + " overlap = set(r.methods or ()) & meths\n", + " if (r.path == route.path and getattr(r, 'host', None) == getattr(route, 'host', None)\n", + " and overlap):\n", + " raise ValueError(f\"Overlapping route methods {sorted(overlap)!r} for path {route.path!r}\")\n", + " routes.append(r)\n", + " self.router.routes = routes + [route]\n", " self.router.routes.sort(key=lambda r: not getattr(r, 'host', None))\n" ] }, diff --git a/tests/test_routing.py b/tests/test_routing.py new file mode 100644 index 00000000..8f1b21d6 --- /dev/null +++ b/tests/test_routing.py @@ -0,0 +1,34 @@ +import pytest + +from fasthtml.core import FastHTML + + +def _app(): + return FastHTML(secret_key="synthetic-test-only", sess_cls=None) + + +def test_default_route_rejects_overlapping_sibling_method(): + app = _app() + app.route("/shared")(lambda req: "default") + + with pytest.raises(ValueError, match="POST"): + app.post("/shared")(lambda req: "post") + + +def test_explicit_disjoint_methods_can_share_path(): + app = _app() + app.get("/shared", name="shared_get")(lambda req: "get") + app.post("/shared", name="shared_post")(lambda req: "post") + + assert [route.name for route in app.router.routes if route.path == "/shared"] == [ + "shared_get", + "shared_post", + ] + + +def test_same_name_and_methods_replace_existing_route(): + app = _app() + app.get("/shared", name="shared")(lambda req: "first") + app.get("/shared", name="shared")(lambda req: "replacement") + + assert len([route for route in app.router.routes if route.path == "/shared"]) == 1