From 7497248e78ab2ed126e3bebb014a488dee616566 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 21 Jun 2026 12:49:29 +0300 Subject: [PATCH] feat(validation): runtime body_model validation with 422 errors Implements #100 by passing body_model down to Rust and validating the JSON body via Pydantic model_validate at runtime. Validation errors produce a 422 response. --- oxyroute/app.py | 1 + src/dispatch.rs | 50 +++++++++++++++++++++++++++++++---- src/lib.rs | 4 ++- src/state.rs | 2 ++ tests/test_validation.py | 57 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 tests/test_validation.py diff --git a/oxyroute/app.py b/oxyroute/app.py index ea26cc9..d4dd619 100644 --- a/oxyroute/app.py +++ b/oxyroute/app.py @@ -382,6 +382,7 @@ def wrap(handler: F) -> F: jwt_leeway, jwt_cookie, body_schema_json, + body_model, ) return handler diff --git a/src/dispatch.rs b/src/dispatch.rs index 5867377..73bc7bf 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -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) @@ -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"); @@ -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 { 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 { @@ -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::() + .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 { @@ -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; diff --git a/src/lib.rs b/src/lib.rs index 836900b..0c2ca6c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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( @@ -183,6 +183,7 @@ impl App { jwt_leeway: Option, jwt_cookie: Option, body_schema_json: Option, + body_model: Option>, ) -> PyResult<()> { { let st = self.state.read(); @@ -268,6 +269,7 @@ impl App { handler_param_names: Arc::new(handler_param_names), handler_varkw, trivial_sync, + body_model, }); let request_schema: Option = match body_schema_json .as_deref() diff --git a/src/state.rs b/src/state.rs index ae2256e..1f362ed 100644 --- a/src/state.rs +++ b/src/state.rs @@ -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>, } /// True when the route can be served by [`try_rsgi_sync_short_circuit`](crate::dispatch::try_rsgi_sync_short_circuit) diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..edc46c8 --- /dev/null +++ b/tests/test_validation.py @@ -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())