patx/wred
Pre-split dynamic routes and combine repeated Cookie headers
Commit 84c8364 · patx · 2026-10-17T22:39:51-04:00
Comments
No comments yet.
Diff
diff --git a/README.md b/README.md
index 51e6c68..75f0f72 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,13 @@ Unsupported protocols raise `RuntimeError` without sending HTTP messages,
following [ASGI guidance](https://asgi.readthedocs.io/en/latest/specs/main.html#protocol-specifications).
You can also export a middleware wrapper as `app`.
+For public deployments, serve Wred behind a reverse proxy such as
+[Nginx](https://nginx.org/en/docs/http/ngx_http_core_module.html#client_max_body_size)
+or [Cloudflare](https://developers.cloudflare.com/waf/). Configure request-body
+size limits, HTTPS, timeouts, and rate limiting at that layer. Wred buffers request
+bodies in memory and does not impose a body-size limit. Restrict direct access to
+the ASGI server so clients cannot bypass the proxy's protections.
+
## Routes
Handlers are async and receive a request. Methods default to `['GET']`;
@@ -95,7 +102,7 @@ with pretty much every ASGI application you can think of.
* `request.path` -> Path without the query string
* `request.method` -> HTTP method
-* `request.headers` -> Header dict with lowercase names and Latin-1 values; repeated names keep their last value
+* `request.headers` -> Header dict with lowercase names and Latin-1 values; repeated Cookie fields join with `; `, other repeated names keep their last value
* `request.scope` -> Original ASGI scope, including client, scheme, and middleware state
* `request.query()` -> Cached dict of string keys and values. No `await`!
* `await request.body()` -> Cached body raw bytes
diff --git a/tests/test_wred.py b/tests/test_wred.py
index d4bd142..095f822 100644
--- a/tests/test_wred.py
+++ b/tests/test_wred.py
@@ -368,9 +368,10 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
return next(chunks)
messages = await call_app(app, {
- 'type': 'http', 'http_version': '1.1', 'path': '/stream', 'method': 'POST',
+ 'type': 'http', 'http_version': '2', 'path': '/stream', 'method': 'POST',
'headers': [(b'X-Example', b'caf\xe9'),
- (b'Cookie', b'notebook_refresh=valid-token; theme=dark; lang=en')],
+ (b'Cookie', b'notebook_refresh=valid-token'),
+ (b'cookie', b'theme=dark'), (b'COOKIE', b'lang=en')],
}, receive)
self.assertEqual(reads, 3)
self.assertEqual(messages[0]['status'], 200)
diff --git a/wred.py b/wred.py
index 3cf0353..3b044e0 100644
--- a/wred.py
+++ b/wred.py
@@ -27,7 +27,8 @@ class Request:
def __init__(self, scope: dict[str, Any], receive: Callable[[], Awaitable[dict[str, Any]]], send: Callable[[dict[str, Any]], Awaitable[None]]):
self.scope, self.receive, self.send = scope, receive, send
self.path, self.method = scope['path'], scope['method']
- self.headers = {k.decode().lower(): v.decode('latin-1') for k, v in scope.get('headers', [])}
+ self.headers = {}
+ for k, v in scope.get('headers', []): k = k.decode('latin-1').lower(); v = v.decode('latin-1'); self.headers[k] = self.headers[k] + '; ' + v if k == 'cookie' and k in self.headers else v
self._body = self._query = None
def query(self) -> dict[str, str]:
@@ -60,7 +61,7 @@ class App:
"""A minimal ASGI application framework."""
def __init__(self):
self.routes: dict[str, dict[str, Callable]] = {}
- self.pattern_routes: dict[str, dict[str, Callable]] = {}
+ self.pattern_routes: dict[tuple[str, ...], dict[str, Callable]] = {}
async def __call__(self, scope: dict[str, Any], receive: Callable[[], Awaitable[dict[str, Any]]], send: Callable[[dict[str, Any]], Awaitable[None]]) -> None:
"""Handle ASGI requests."""
@@ -80,8 +81,9 @@ class App:
if path in self.routes and method in self.routes[path]:
await self.dispatch_response(request, await self.routes[path][method](request))
return
+ path_parts = path.split('/')
for pattern, method_handlers in self.pattern_routes.items():
- params = self.match_path(pattern, path)
+ params = self.match_path(pattern, path_parts)
if params is not None and method in method_handlers:
await self.dispatch_response(request, await method_handlers[method](request, **params))
return
@@ -109,10 +111,8 @@ class App:
await send({'type': 'http.response.start', 'status': status, 'headers': [(b'content-type', content_type.encode('latin-1'))] + headers})
await send({'type': 'http.response.body', 'body': body})
- def match_path(self, pattern: str, path: str) -> dict[str, str] | None:
+ def match_path(self, pattern_parts: tuple[str, ...], path_parts: list[str]) -> dict[str, str] | None:
"""Match normalized paths and extract parameters."""
- pattern_parts = pattern.split('/')
- path_parts = path.split('/')
if len(pattern_parts) != len(path_parts):
return None
params = {}
@@ -143,7 +143,8 @@ class App:
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})
+ key = tuple(path.split('/')) if routes is self.pattern_routes else path
+ routes.setdefault(key, {}).update({m.upper(): handler for m in methods})
return handler
return decorator