Normalize trailing slashes consistently across routes

Commit a96062c · patx · 2026-10-17T19:29:09-04:00

Changeset
a96062c5cda1d1a99d684537f5a565bb411da46f
Parents
cb7592b55bde231776bc22761aa8ceadba6e2077

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/README.md b/README.md
index f912317..18b436a 100644
--- a/README.md
+++ b/README.md
@@ -76,6 +76,8 @@ async def update_user(request, user_id):
 Different methods can have separate handlers on the same path. Fixed routes
 win over dynamic routes for that method; dynamic routes follow registration
 order. Registering the same path and method again replaces its handler.
+Trailing slashes are ignored when registering and matching routes: `/users`
+and `/users/` use the same handler, without a redirect.
 Unmatched paths or methods return JSON 404. No automatic HEAD, OPTIONS, or 405.
 
 If you mount your Wred application at `/api`, a `/users` route in that app
diff --git a/tests/test_wred.py b/tests/test_wred.py
index 8188d87..f8823a5 100644
--- a/tests/test_wred.py
+++ b/tests/test_wred.py
@@ -83,6 +83,7 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
             ('/api/items/alice', '/api', 'alice'),
             ('/api/items/alice', '/api/', 'alice'),
             ('/api/items/special', '/api', 'fixed'),
+            ('/api/items/special/', '/api', 'fixed'),
             ('/proxy/api/items/alice', '/proxy/api', 'alice'),
             ('/api', '/api', 'fixed'),
             ('/api/', '/api', 'fixed'),
@@ -290,14 +291,20 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
         async def update_person(request, name):
             return {'updated': name}
 
+        @app.route('/people/alice/', methods=['PUT'])
         @app.route('/people/alice')
         async def alice(request):
             return {'fixed': True}
 
         cases = [
             ('GET', '/people/alice', b'{"fixed":true}'),
+            ('GET', '/people/alice/', b'{"fixed":true}'),
             ('GET', '/people/bob', b'{"name":"bob"}'),
+            ('GET', '/people/bob/', b'{"name":"bob"}'),
             ('POST', '/people/alice', b'{"updated":"alice"}'),
+            ('POST', '/people/alice/', b'{"updated":"alice"}'),
+            ('PUT', '/people/alice', b'{"fixed":true}'),
+            ('PUT', '/people/alice/', b'{"fixed":true}'),
         ]
         for method, path, body in cases:
             with self.subTest(method=method, path=path):
diff --git a/wred.py b/wred.py
index 5ee7431..704c249 100644
--- a/wred.py
+++ b/wred.py
@@ -80,6 +80,7 @@ class App:
         root_path = request.scope.get('root_path', '').rstrip('/')
         if root_path and (path == root_path or path.startswith(root_path + '/')):
             path = path[len(root_path):] or '/'
+        path = path.rstrip('/') or '/'
         method = request.method
         if path in self.routes and method in self.routes[path]:
             handler = self.routes[path][method]
@@ -124,9 +125,9 @@ class App:
         await send({'type': 'http.response.body', 'body': body})
 
     def match_path(self, pattern: str, path: str) -> dict[str, str] | None:
-        """Match a path against a pattern and extract parameters."""
-        pattern_parts = pattern.strip('/').split('/')
-        path_parts = path.strip('/').split('/')
+        """Match normalized paths and extract parameters."""
+        pattern_parts = pattern.split('/')
+        path_parts = path.split('/')
         if len(pattern_parts) != len(path_parts):
             return None
 
@@ -156,6 +157,7 @@ class App:
 
     def route(self, path: str, methods: list[str] | tuple[str, ...] = ('GET',)) -> Callable:
         """Decorator to register routes with specific HTTP methods."""
+        path = path.rstrip('/') or '/'
         def decorator(handler: Callable) -> Callable:
             routes = self.pattern_routes if '{' in path and '}' in path else self.routes
             routes.setdefault(path, {}).update({m.upper(): handler for m in methods})