Normalize header handling and simplify request dispatch

Commit 53388e7 · patx · 2026-10-17T22:09:12-04:00

Changeset
53388e780d105a0c441843c2a6bc01fa192ed2cc
Parents
a96062c5cda1d1a99d684537f5a565bb411da46f

View source at this commit

Comments

No comments yet.

Log in to comment

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: