Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions oxyroute/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,6 +19,7 @@
"DBQuery",
"Depends",
"HTTPException",
"Request",
"Response",
"SSEEvent",
"SecurityHeadersConfig",
Expand Down
72 changes: 72 additions & 0 deletions oxyroute/request.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -932,7 +932,7 @@ pub async fn run_rsgi(
match Python::with_gil(|py| -> PyResult<Py<PyAny>> {
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) => {
Expand Down
41 changes: 4 additions & 37 deletions src/params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,43 +15,10 @@ pub fn build_request_context<'py>(
method: &str,
path: &str,
query_string: &str,
) -> PyResult<Bound<'py, PyDict>> {
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<Bound<'py, PyDict>> {
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::<PyDict>() {
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::<PyDict>() {
for (k, v) in hd.iter() {
let ks: String = k.extract()?;
let vs: String = v.extract()?;
out.set_item(ks, vs)?;
}
}
Ok(out)
) -> PyResult<Bound<'py, PyAny>> {
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 `?`).
Expand Down
3 changes: 3 additions & 0 deletions tests/_rsgi_test_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
24 changes: 24 additions & 0 deletions tests/test_dep_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading