patx/wred
Normalize header handling and simplify request dispatch
Commit 53388e7 · patx · 2026-10-17T22:09:12-04:00
Comments
No comments yet.
Diff
diff --git a/README.md b/README.md
index 18b436a..51e6c68 100644
--- a/README.md
+++ b/README.md
@@ -95,7 +95,7 @@ with pretty much every ASGI application you can think of.
* `request.path` -> Path without the query string
* `request.method` -> HTTP method
-* `request.headers` -> Decoded header dict; preserves header name casing supplied by the server
+* `request.headers` -> Header dict with lowercase names and Latin-1 values; 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 f8823a5..d4bd142 100644
--- a/tests/test_wred.py
+++ b/tests/test_wred.py
@@ -359,7 +359,8 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
parsed = await request.json()
cached = await request.body()
return {'raw': first.decode(), 'cached': cached.decode(),
- 'parsed': parsed, 'header': request.headers['x-example']}
+ 'parsed': parsed, 'header': request.headers['x-example'],
+ 'cookie': request.headers['cookie']}
async def receive():
nonlocal reads
@@ -367,14 +368,16 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
return next(chunks)
messages = await call_app(app, {
- 'type': 'http', 'path': '/stream', 'method': 'POST',
- 'headers': [(b'x-example', b'hello')],
+ 'type': 'http', 'http_version': '1.1', 'path': '/stream', 'method': 'POST',
+ 'headers': [(b'X-Example', b'caf\xe9'),
+ (b'Cookie', b'notebook_refresh=valid-token; theme=dark; lang=en')],
}, receive)
self.assertEqual(reads, 3)
self.assertEqual(messages[0]['status'], 200)
self.assertEqual(orjson.loads(messages[1]['body']), {
'raw': '{"name":"Alice"}', 'cached': '{"name":"Alice"}',
- 'parsed': {'name': 'Alice'}, 'header': 'hello',
+ 'parsed': {'name': 'Alice'}, 'header': 'café',
+ 'cookie': 'notebook_refresh=valid-token; theme=dark; lang=en',
})
async def test_disconnect_stops_body_reading(self):
diff --git a/wred.py b/wred.py
index 704c249..3cf0353 100644
--- a/wred.py
+++ b/wred.py
@@ -10,7 +10,7 @@ Copyright (c) 2026, Harrison Erd.
__author__ = 'Harrison Erd'
__email__ = '[email protected]'
-__version__ = '0.07'
+__version__ = '0.08'
__license__ = 'BSD-3-Clause'
from collections.abc import Awaitable, Callable
@@ -25,14 +25,10 @@ import orjson
class Request:
"""A request object encapsulating ASGI scope, receive, and send."""
def __init__(self, scope: dict[str, Any], receive: Callable[[], Awaitable[dict[str, Any]]], send: Callable[[dict[str, Any]], Awaitable[None]]):
- self.scope = scope
- self.receive = receive
- self.send = send
- self.path = scope['path']
- self.method = scope['method']
- self.headers = {k.decode(): v.decode() for k, v in scope.get('headers', [])}
- self._body = None
- self._query = 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._body = self._query = None
def query(self) -> dict[str, str]:
"""Parse and cache query parameters; repeated keys keep their last value."""
@@ -70,7 +66,6 @@ class App:
"""Handle ASGI requests."""
if scope['type'] != 'http':
raise RuntimeError(f"Unsupported ASGI scope type: {scope['type']}")
-
request = Request(scope, receive, send)
await self.handle_http(request)
@@ -83,19 +78,13 @@ class App:
path = path.rstrip('/') or '/'
method = request.method
if path in self.routes and method in self.routes[path]:
- handler = self.routes[path][method]
- response = await handler(request)
- await self.dispatch_response(request, response)
+ await self.dispatch_response(request, await self.routes[path][method](request))
return
-
for pattern, method_handlers in self.pattern_routes.items():
params = self.match_path(pattern, path)
if params is not None and method in method_handlers:
- handler = method_handlers[method]
- response = await handler(request, **params)
- await self.dispatch_response(request, response)
+ await self.dispatch_response(request, await method_handlers[method](request, **params))
return
-
await self.send_response(request.send, {"error": "Not Found"}, status=404)
async def dispatch_response(self, request: Request, response: Any) -> None:
@@ -117,11 +106,7 @@ class App:
body, content_type = orjson.dumps(body), 'application/json'
elif not isinstance(body, bytes):
raise TypeError("Response must be a dictionary, list, bytes, or response tuple")
- await send({
- 'type': 'http.response.start',
- 'status': status,
- 'headers': [(b'content-type', content_type.encode('ascii'))] + headers,
- })
+ 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:
@@ -130,7 +115,6 @@ class App:
path_parts = path.split('/')
if len(pattern_parts) != len(path_parts):
return None
-
params = {}
for pattern_part, path_part in zip(pattern_parts, path_parts):
if pattern_part.startswith('{') and pattern_part.endswith('}'):
@@ -152,7 +136,6 @@ class App:
except (OSError, ValueError, RuntimeError):
return b'Not Found', 'text/plain; charset=utf-8', 404
return body, guess_type(path.name)[0] or 'application/octet-stream', 200
-
return await to_thread(read)
def route(self, path: str, methods: list[str] | tuple[str, ...] = ('GET',)) -> Callable: