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
1 change: 1 addition & 0 deletions oxyroute/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,7 @@ def wrap(handler: F) -> F:
jwt_leeway,
jwt_cookie,
body_schema_json,
body_model,
)
return handler

Expand Down
50 changes: 45 additions & 5 deletions src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,7 @@ pub async fn run_rsgi(
dep_wants_request,
handler_param_names,
handler_varkw,
body_model,
) = Python::with_gil(|_py| -> PyResult<_> {
let e = routes_arc
.get(route_idx)
Expand All @@ -671,6 +672,7 @@ pub async fn run_rsgi(
Arc::clone(&e.dep_wants_request),
Arc::clone(&e.handler_param_names),
e.handler_varkw,
e.body_model.clone(),
))
})?;
let may_need_raw_body = handler_varkw || handler_param_names.contains("body");
Expand Down Expand Up @@ -1105,10 +1107,15 @@ pub async fn run_rsgi(
|| should_pass_files
|| should_pass_body
|| should_pass_protocol;
let (res, run_async) = match Python::with_gil(|py| -> PyResult<(PyObject, bool)> {
enum RunHandlerResult {
Ok((PyObject, bool)),
ValidationError(String),
}

let (res, run_async) = match Python::with_gil(|py| -> PyResult<RunHandlerResult> {
if !should_use_kwargs {
let res = handler.bind(py).call0()?.unbind();
return Ok((res, is_async));
return Ok(RunHandlerResult::Ok((res, is_async)));
}
let kwargs = PyDict::new(py);
for (k, v) in param_map {
Expand All @@ -1135,7 +1142,31 @@ pub async fn run_rsgi(
}
if let Some(ref j) = body_json {
let pyv = json_to_py(py, j)?;
kwargs.set_item("json", pyv)?;
if let Some(ref bm) = body_model {
match bm.bind(py).call_method1("model_validate", (&pyv,)) {
Ok(validated) => {
kwargs.set_item("json", validated)?;
}
Err(e) => {
let err_str: String =
if let Ok(exc_obj) = e.clone_ref(py).into_bound_py_any(py) {
if let Ok(j_method) = exc_obj.call_method0("json") {
j_method
.extract::<String>()
.unwrap_or_else(|_| "[]".to_string())
} else {
"[]".to_string()
}
} else {
"[]".to_string()
};
let err_json = format!(r#"{{"detail":{err_str}}}"#);
return Ok(RunHandlerResult::ValidationError(err_json));
}
}
} else {
kwargs.set_item("json", pyv)?;
}
}
if read_form_body {
if should_pass_form {
Expand Down Expand Up @@ -1167,9 +1198,18 @@ pub async fn run_rsgi(
kwargs.set_item("protocol", protocol.bind(py))?;
}
let res = handler.bind(py).call((), Some(&kwargs))?.unbind();
Ok((res, is_async))
Ok(RunHandlerResult::Ok((res, is_async)))
}) {
Ok(x) => x,
Ok(RunHandlerResult::Ok((res, is_async))) => (res, is_async),
Ok(RunHandlerResult::ValidationError(err_json)) => {
return response::send_text(
&protocol,
422,
&err_json,
"application/json; charset=utf-8",
)
.await;
}
Err(e) => {
return send_python_error(&protocol, &method, &path, e, Some(&scope), Some(&state))
.await;
Expand Down
4 changes: 3 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ impl App {

/// Paths use **matchit 0.7** style: `/user/:id`. Pass `dependencies=[("x", get_x), ...]`.
#[pyo3(
signature = (method, path, handler, require_jwt=false, jwt_secret=None, algorithms=None, read_json_body=true, read_form_body=false, dependencies=None, jwt_issuer=None, jwt_audience=None, jwt_leeway=None, jwt_cookie=None, body_schema_json=None)
signature = (method, path, handler, require_jwt=false, jwt_secret=None, algorithms=None, read_json_body=true, read_form_body=false, dependencies=None, jwt_issuer=None, jwt_audience=None, jwt_leeway=None, jwt_cookie=None, body_schema_json=None, body_model=None)
)]
#[allow(clippy::too_many_arguments)]
fn add_route(
Expand All @@ -183,6 +183,7 @@ impl App {
jwt_leeway: Option<u64>,
jwt_cookie: Option<String>,
body_schema_json: Option<String>,
body_model: Option<Py<PyAny>>,
) -> PyResult<()> {
{
let st = self.state.read();
Expand Down Expand Up @@ -268,6 +269,7 @@ impl App {
handler_param_names: Arc::new(handler_param_names),
handler_varkw,
trivial_sync,
body_model,
});
let request_schema: Option<serde_json::Value> = match body_schema_json
.as_deref()
Expand Down
2 changes: 2 additions & 0 deletions src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ pub struct RouteEntry {
pub handler_varkw: bool,
/// Sync ``call0()`` route with no body/JWT/deps/kwargs — eligible for RSGI sync fast path.
pub trivial_sync: bool,
/// Pydantic model for request body validation.
pub body_model: Option<Py<PyAny>>,
}

/// True when the route can be served by [`try_rsgi_sync_short_circuit`](crate::dispatch::try_rsgi_sync_short_circuit)
Expand Down
57 changes: 57 additions & 0 deletions tests/test_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import asyncio

import httpx
from oxyroute import App
from pydantic import BaseModel
from tests._rsgi_test_transport import asgi_test_app


class UserBody(BaseModel):
name: str
age: int


def test_body_model_validation_success() -> None:
app = App()
seen: dict[str, object] = {}

@app.post("/user", body_model=UserBody)
def create_user(json: UserBody) -> str:
seen["user"] = json
return json.name

async def _run() -> None:
transport = httpx.ASGITransport(app=asgi_test_app(app))
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
r = await c.post("/user", json={"name": "Alice", "age": 30})
assert r.status_code == 200, r.text
assert r.text == "Alice"

asyncio.run(_run())
user = seen["user"]
assert isinstance(user, UserBody)
assert user.name == "Alice"
assert user.age == 30


def test_body_model_validation_failure_422() -> None:
app = App()

@app.post("/user", body_model=UserBody)
def create_user(json: UserBody) -> str:
return json.name

async def _run() -> None:
transport = httpx.ASGITransport(app=asgi_test_app(app))
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
# Missing 'age', should fail validation
r = await c.post("/user", json={"name": "Bob"})

assert r.status_code == 422
data = r.json()
assert "detail" in data
assert isinstance(data["detail"], list)
assert data["detail"][0]["type"] == "missing"
assert data["detail"][0]["loc"] == ["age"]

asyncio.run(_run())
Loading