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: 7 additions & 0 deletions docs/handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions oxyroute/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
88 changes: 61 additions & 27 deletions src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PyAny> = 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<PyAny> = p.call0()?;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -560,6 +560,40 @@ enum HandlerMap {
},
}

async fn send_handler_map(
protocol: &Py<PyAny>,
is_head: bool,
mapped: HandlerMap,
) -> PyResult<PyObject> {
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<bool> {
// `oxyroute.Response` (dataclass): not a plain dict; has instance attributes
// `status` / `body` / `headers` (and optional `cookies`). Avoid `isinstance` /
Expand Down
8 changes: 8 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Py<PyAny>>) -> PyResult<()> {
let mut st = self.state.lock();
st.middleware = handler;
Ok(())
}

fn handle_rsgi<'py>(
this: PyRef<'py, Self>,
py: Python<'py>,
Expand Down
3 changes: 3 additions & 0 deletions src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Py<PyAny>>,
}

impl AppState {
Expand All @@ -65,6 +67,7 @@ impl AppState {
openapi: Mutex::new(openapi),
frozen: false,
include_openapi: true,
middleware: None,
}
}
}
Expand Down
44 changes: 44 additions & 0 deletions tests/test_middleware.py
Original file line number Diff line number Diff line change
@@ -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())
Loading