diff --git a/docs/handlers.md b/docs/handlers.md index 93e9ef0..d298b08 100644 --- a/docs/handlers.md +++ b/docs/handlers.md @@ -46,6 +46,13 @@ If a **dependency factory** or the **route handler** raises a Python exception ( Set the environment variable **`OXYROUTE_DEBUG=1`** (or `true`) to include a **`detail`** string in that JSON for the same error and to log more at the `log` crate target **`oxyroute`** (see `RUST_LOG`, e.g. `RUST_LOG=oxyroute=error`). +## Pre-route hook (`set_middleware`) + +`App.set_middleware(f)` sets an **optional** callable taking `(scope, protocol)` (same RSGI-like objects as the rest of the stack). It runs **after** the path and method are known, **before** the request body is read or routes are matched. + +- Return **`None`**: continue with normal routing and body read. +- Return **any other value**: use the same mapping as a route return value (`Response`, dict, `str`, etc.); the response is sent and **the route handler and body are skipped** (e.g. cheap CORS preflight on `OPTIONS` without consuming a `POST` body). + ## See also - [Routing](routing.md) diff --git a/oxyroute/app.py b/oxyroute/app.py index 965cc41..c057b62 100644 --- a/oxyroute/app.py +++ b/oxyroute/app.py @@ -50,6 +50,17 @@ def set_openapi_served(self, enabled: bool) -> None: """Enable or disable the built-in ``GET /openapi.json`` route.""" self._app.set_openapi_served(enabled) + def set_middleware(self, handler: Callable[..., Any] | None) -> None: + """ + One optional pre-route callback ``(scope, protocol)`` — return ``None`` to pass through. + + For any other return value, the same rules apply as for route handlers + (e.g. :class:`oxyroute.Response` or a ``dict`` with ``status`` / ``body`` / ``headers``); + the response is sent and routing / body read is skipped. Runs **before** the request + body is read (e.g. for CORS preflight). + """ + self._app.set_middleware(handler) + def get( self, path: str, diff --git a/src/dispatch.rs b/src/dispatch.rs index 11d769d..cbccbe9 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -96,6 +96,32 @@ pub async fn run_rsgi( .await; } } + let maybe_mw = { + let st = state.lock(); + st.middleware.clone() + }; + if let Some(mw) = maybe_mw { + let out: Py = match Python::with_gil(|py| { + let f = mw.bind(py); + f.call1((scope.bind(py), protocol.bind(py))) + .map(|b| b.unbind()) + }) { + Ok(x) => x, + Err(e) => { + return send_internal_error(&protocol, &method, &path, e).await; + } + }; + let skip = Python::with_gil(|py| out.bind(py).is_none()); + if !skip { + let mapped = match Python::with_gil(|py| map_handler_return(py, &out)) { + Ok(m) => m, + Err(e) => { + return send_internal_error(&protocol, &method, &path, e).await; + } + }; + return send_handler_map(&protocol, is_head, mapped).await; + } + } let read_fut = Python::with_gil(|py| { let p = protocol.bind(py); let aw: Bound = p.call0()?; @@ -452,33 +478,7 @@ pub async fn run_rsgi( return send_internal_error(&protocol, &method, &path, e).await; } }; - if is_head { - match mapped { - HandlerMap::WithHeaders { - status, - body, - headers, - } => response::send_head_with_headers(&protocol, status, &body, headers).await, - HandlerMap::Simple { - status, - body, - content_type, - } => response::send_head_simple(&protocol, status, body.len(), &content_type).await, - } - } else { - match mapped { - HandlerMap::WithHeaders { - status, - body, - headers, - } => response::send_with_headers(&protocol, status, &body, headers).await, - HandlerMap::Simple { - status, - body, - content_type, - } => response::send_bytes(&protocol, status, &body, &content_type).await, - } - } + send_handler_map(&protocol, is_head, mapped).await } /// Return value of a user handler, mapped to an HTTP body and headers. @@ -560,6 +560,40 @@ enum HandlerMap { }, } +async fn send_handler_map( + protocol: &Py, + is_head: bool, + mapped: HandlerMap, +) -> PyResult { + if is_head { + match mapped { + HandlerMap::WithHeaders { + status, + body, + headers, + } => response::send_head_with_headers(protocol, status, &body, headers).await, + HandlerMap::Simple { + status, + body, + content_type, + } => response::send_head_simple(protocol, status, body.len(), &content_type).await, + } + } else { + match mapped { + HandlerMap::WithHeaders { + status, + body, + headers, + } => response::send_with_headers(protocol, status, &body, headers).await, + HandlerMap::Simple { + status, + body, + content_type, + } => response::send_bytes(protocol, status, &body, &content_type).await, + } + } +} + fn is_oxyroute_response(_py: Python<'_>, b: &Bound<'_, PyAny>) -> PyResult { // `oxyroute.Response` (dataclass): not a plain dict; has instance attributes // `status` / `body` / `headers` (and optional `cookies`). Avoid `isinstance` / diff --git a/src/lib.rs b/src/lib.rs index 8f83c97..3c84f58 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -297,6 +297,14 @@ impl App { Ok(()) } + /// Single optional pre-route hook. Return ``None`` to continue; otherwise the return value + /// is mapped like a route handler (e.g. :class:`oxyroute.Response`, ``dict`` with ``status`` / ``body`` / ``headers``). + fn set_middleware(&self, handler: Option>) -> PyResult<()> { + let mut st = self.state.lock(); + st.middleware = handler; + Ok(()) + } + fn handle_rsgi<'py>( this: PyRef<'py, Self>, py: Python<'py>, diff --git a/src/state.rs b/src/state.rs index 981c936..db4c258 100644 --- a/src/state.rs +++ b/src/state.rs @@ -45,6 +45,8 @@ pub struct AppState { pub frozen: bool, /// Serve `GET /openapi.json` from the built document without a user route. pub include_openapi: bool, + /// Optional `(scope, protocol) ->` hook; return ``None`` to continue to routing (see `docs/handlers.md`). + pub middleware: Option>, } impl AppState { @@ -65,6 +67,7 @@ impl AppState { openapi: Mutex::new(openapi), frozen: false, include_openapi: true, + middleware: None, } } } diff --git a/tests/test_middleware.py b/tests/test_middleware.py new file mode 100644 index 0000000..90f65a1 --- /dev/null +++ b/tests/test_middleware.py @@ -0,0 +1,44 @@ +"""Pre-route `set_middleware` short-circuits before body read (CORS preflight, issue #11).""" + +from __future__ import annotations + +import asyncio + +import httpx +from oxyroute import App, Response + + +def test_middleware_cors_preflight_204_no_route_ran() -> None: + n = 0 + + def mw(scope, _protocol) -> Response | None: + if scope.method == "OPTIONS" and scope.headers.get("access-control-request-method", ""): + return Response( + status=204, + body=None, + headers={"access-control-allow-origin": "*"}, + ) + return None + + app = App() + app.set_middleware(mw) + + @app.post("/x") + def _x() -> str: + nonlocal n + n += 1 + return "ok" + + async def _run() -> None: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.request( + "OPTIONS", + "/x", + headers={"access-control-request-method": "POST"}, + ) + assert r.status_code == 204, r.text + assert (r.headers.get("access-control-allow-origin") or "") == "*" + assert n == 0 + + asyncio.run(_run())