patx/wred
Normalize trailing slashes consistently across routes
Commit a96062c · patx · 2026-10-17T19:29:09-04:00
Comments
No comments yet.
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})