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