From 8e0e569ddd4557f9e918a65cfd4aaf972ce1959a Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 21 Jun 2026 13:22:14 +0300 Subject: [PATCH] feat(request): introduce typed Request object with lazy headers --- docs/dependencies.md | 7 +++- oxyroute/__init__.py | 2 + oxyroute/request.py | 72 +++++++++++++++++++++++++++++++++++ src/dispatch.rs | 2 +- src/params.rs | 41 ++------------------ tests/_rsgi_test_transport.py | 3 ++ tests/test_dep_chain.py | 24 ++++++++++++ 7 files changed, 112 insertions(+), 39 deletions(-) create mode 100644 oxyroute/request.py diff --git a/docs/dependencies.md b/docs/dependencies.md index 37403c5..d0106be 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -6,7 +6,12 @@ OxyRoute supports a **linear** list of **named** dependency factories. At reques ### Request context (optional) -If a factory’s signature includes a parameter named `request`, the extension passes a **dict** (once per request, shared) with string keys: `method`, `path`, `query_string`, and `headers` (a flat `str` → `str` map, when the underlying RSGI scope exposes headers). Factories that do **not** declare `request` are still called with **no** extra arguments when they have no prior dependencies, preserving older behavior. +If a factory’s signature includes a parameter named `request`, the extension passes an `oxyroute.Request` object (once per request, shared). The `Request` object has typed accessors: `method`, `path`, `query_string`, `headers`, `client`, and `cookies`. For backwards compatibility, it can still be accessed like a dictionary (e.g. `request["headers"]`). + +### Lazy Headers +To avoid performance overhead when headers are not needed by the handler or dependencies, the `Request.headers` dict is populated lazily. The full header map is only built on the first access of `.headers` (or `request["headers"]`). + +Factories that do **not** declare `request` are still called with **no** extra arguments when they have no prior dependencies, preserving older behavior. ## Declaring on a route diff --git a/oxyroute/__init__.py b/oxyroute/__init__.py index cdd0111..0c11df6 100644 --- a/oxyroute/__init__.py +++ b/oxyroute/__init__.py @@ -5,6 +5,7 @@ from oxyroute.cors import CORSConfig, apply_cors from oxyroute.csrf import CSRFConfig, apply_csrf, csrf_layer from oxyroute.exceptions import HTTPException +from oxyroute.request import Request from oxyroute.response import Response from oxyroute.router import APIRouter from oxyroute.security_headers import SecurityHeadersConfig @@ -18,6 +19,7 @@ "DBQuery", "Depends", "HTTPException", + "Request", "Response", "SSEEvent", "SecurityHeadersConfig", diff --git a/oxyroute/request.py b/oxyroute/request.py new file mode 100644 index 0000000..bfb79cc --- /dev/null +++ b/oxyroute/request.py @@ -0,0 +1,72 @@ +from collections.abc import Iterator, Mapping +from typing import Any + + +class Request(Mapping[str, Any]): + """ + A typed Request context object providing lazy access to headers and other scope properties. + Preserves dictionary-like access (`request["headers"]`) for backwards compatibility. + """ + + def __init__(self, scope: Any, method: str, path: str, query_string: str) -> None: + self.scope = scope + self.method = method + self.path = path + self.query_string = query_string + self._headers: dict[str, str] | None = None + + @property + def headers(self) -> dict[str, str]: + if self._headers is None: + if isinstance(self.scope, dict): + h = self.scope.get("headers", []) + if isinstance(h, dict): + self._headers = {str(k): str(v) for k, v in h.items()} + else: + self._headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in h} + else: + h = getattr(self.scope, "headers", {}) + if hasattr(h, "_d"): + h = h._d + self._headers = dict(h) + return self._headers + + @property + def client(self) -> str | None: + if isinstance(self.scope, dict): + client = self.scope.get("client") + if client: + return f"{client[0]}:{client[1]}" + else: + return getattr(self.scope, "client", None) + return None + + @property + def cookies(self) -> dict[str, str]: + # Minimal cookie parsing from headers + cookie_header = self.headers.get("cookie") + if not cookie_header: + return {} + cookies = {} + for chunk in cookie_header.split(";"): + if "=" in chunk: + k, v = chunk.split("=", 1) + cookies[k.strip()] = v.strip() + return cookies + + def __getitem__(self, key: str) -> Any: + if key == "headers": + return self.headers + if key == "method": + return self.method + if key == "path": + return self.path + if key == "query_string": + return self.query_string + raise KeyError(key) + + def __iter__(self) -> Iterator[str]: + return iter(["method", "path", "query_string", "headers"]) + + def __len__(self) -> int: + return 4 diff --git a/src/dispatch.rs b/src/dispatch.rs index 73bc7bf..69b66ba 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -932,7 +932,7 @@ pub async fn run_rsgi( match Python::with_gil(|py| -> PyResult> { let s = scope.bind(py); let d = build_request_context(py, s, &method, &path, &query_string)?; - Ok(d.unbind().into()) + Ok(d.unbind()) }) { Ok(o) => Some(o), Err(e) => { diff --git a/src/params.rs b/src/params.rs index c1af15b..3c33063 100644 --- a/src/params.rs +++ b/src/params.rs @@ -15,43 +15,10 @@ pub fn build_request_context<'py>( method: &str, path: &str, query_string: &str, -) -> PyResult> { - let d = PyDict::new(py); - d.set_item("method", method)?; - d.set_item("path", path)?; - d.set_item("query_string", query_string)?; - d.set_item("headers", copy_scope_headers_to_dict(py, scope)?)?; - Ok(d) -} - -/// Best-effort copy of RSGI/ASGI scope `headers` into a `dict` of strings. -fn copy_scope_headers_to_dict<'py>( - py: Python<'py>, - scope: &Bound<'py, PyAny>, -) -> PyResult> { - let out = PyDict::new(py); - let h = match scope.getattr("headers") { - Ok(x) => x, - Err(_) => return Ok(out), - }; - if let Ok(inner) = h.getattr("_d") { - if let Ok(hd) = inner.downcast::() { - for (k, v) in hd.iter() { - let ks: String = k.extract()?; - let vs: String = v.extract()?; - out.set_item(ks, vs)?; - } - return Ok(out); - } - } - if let Ok(hd) = h.downcast::() { - for (k, v) in hd.iter() { - let ks: String = k.extract()?; - let vs: String = v.extract()?; - out.set_item(ks, vs)?; - } - } - Ok(out) +) -> PyResult> { + let module = py.import("oxyroute.request")?; + let req_cls = module.getattr("Request")?; + req_cls.call1((scope, method, path, query_string)) } /// Parse an HTTP `query` string (the part after `?`, without the `?`). diff --git a/tests/_rsgi_test_transport.py b/tests/_rsgi_test_transport.py index e708904..63a1db4 100644 --- a/tests/_rsgi_test_transport.py +++ b/tests/_rsgi_test_transport.py @@ -342,6 +342,9 @@ async def asgi_to_rsgi( qs.decode("utf-8") if qs else "", hdrs, ) + client = scope.get("client") + if client: + rscope.client = f"{client[0]}:{client[1]}" loop = asyncio.get_running_loop() queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue() diff --git a/tests/test_dep_chain.py b/tests/test_dep_chain.py index 0eb026e..208893b 100644 --- a/tests/test_dep_chain.py +++ b/tests/test_dep_chain.py @@ -52,6 +52,30 @@ async def run() -> None: asyncio.run(run()) +def test_dep_request_context_typed() -> None: + from oxyroute.request import Request + + def with_req(request: Request) -> str: + client = request.client or "unknown" + trace = request.headers.get("x-trace", "none") + return f"{request.method} {request.path} {client} {trace}" + + app = App() + + @app.get("/t2", dependencies=[("info", with_req)]) + def route2(info: str) -> str: + return info + + async def run() -> None: + transport = httpx.ASGITransport(app=asgi_test_app(app), client=("1.2.3.4", 1234)) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.get("/t2", headers={"X-Trace": "y8"}) + assert r.status_code == 200, r.text + assert r.text == "GET /t2 1.2.3.4:1234 y8" + + asyncio.run(run()) + + def test_dep_async_chain() -> None: async def make_a() -> str: return "aa"