From db8a480ab28f584c6736f1ce266c600d5899982d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 26 Apr 2026 17:03:14 +0300 Subject: [PATCH] feat: Response with status, body, headers, cookies (P1 #10) - Add oxyroute.Response dataclass; export from package - dispatch: map returns to RSGI with full header list via response::send_with_headers - Support dict with status/body plus optional headers/cookies (TypedDict-style) - Legacy {status, body} dict unchanged; headers merge with default content-type - ASGI test: 201, application/json, X-Trick header - docs/handlers.md: document structured returns --- docs/handlers.md | 2 + oxyroute/__init__.py | 3 +- oxyroute/response.py | 23 +++++ src/dispatch.rs | 212 ++++++++++++++++++++++++++++++++++++++----- src/response.rs | 56 ++++++++++++ tests/test_asgi.py | 25 ++++- 6 files changed, 294 insertions(+), 27 deletions(-) create mode 100644 oxyroute/response.py diff --git a/docs/handlers.md b/docs/handlers.md index 0c1f37b..9a78850 100644 --- a/docs/handlers.md +++ b/docs/handlers.md @@ -35,6 +35,8 @@ The Rust layer maps the return value to an HTTP response: - **`bytes`:** **200**, `application/octet-stream` - **Any other object (dict, list, custom):** if not a special dict, the value is serialized with **`json.dumps`** and returned as **200** with `application/json; charset=utf-8` - **`dict` with `status` and `body` keys:** if both are present in a way the native code recognizes, a **custom status code** and body (as string) is returned for plain responses (content type fixed in the current path—see `src/dispatch.rs` for the exact check) +- **`dict` with `status`, `body`, and optional `headers` / `cookies`:** same as structured `Response` below; body is encoded like `json` / `str` / `bytes` (not only `str(body)`). `cookies` is a list of raw `Set-Cookie` header values +- **`Response` (from `oxyroute`):** `status`, `body` (optional; `str`, `bytes`, JSON-serializable, or `None` for empty), optional `headers` (`str` → `str`), optional `cookies` (list of strings for `Set-Cookie` lines). If `headers` does not set `content-type`, it is derived from the body type. The RSGI response is built with the full header list For precise behavior and edge cases, refer to the implementation in the repository’s `src/dispatch.rs` and `src/response.rs`. diff --git a/oxyroute/__init__.py b/oxyroute/__init__.py index 8c77ac2..ddf7989 100644 --- a/oxyroute/__init__.py +++ b/oxyroute/__init__.py @@ -3,6 +3,7 @@ from oxyroute.app import App, Depends from oxyroute._oxyroute import decode_jwt_hs +from oxyroute.response import Response -__all__ = ["App", "Depends", "decode_jwt_hs", "__version__"] +__all__ = ["App", "Depends", "Response", "decode_jwt_hs", "__version__"] __version__ = "0.1.0" diff --git a/oxyroute/response.py b/oxyroute/response.py new file mode 100644 index 0000000..26977f0 --- /dev/null +++ b/oxyroute/response.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Mapping, Sequence + +__all__ = ["Response"] + + +@dataclass +class Response: + """ + Structured HTTP response: status, body, and optional extra headers (and ``Set-Cookie`` lines). + + The native dispatcher recognizes this type and calls RSGI with a full header list. + If ``headers`` does not set ``content-type``, one is chosen from the body type + (``text/plain`` for ``str``, ``application/octet-stream`` for ``bytes``, + ``application/json`` for other values after ``json.dumps``). + """ + + body: str | bytes | Any | None = None + status: int = 200 + headers: Mapping[str, str] | None = None + cookies: Sequence[str] | None = None diff --git a/src/dispatch.rs b/src/dispatch.rs index a473aed..7489039 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -5,7 +5,7 @@ use std::sync::Mutex; use jsonwebtoken::errors::ErrorKind; use jsonwebtoken::{DecodingKey, Validation, decode}; use pyo3::prelude::*; -use pyo3::types::{PyBytes, PyDict}; +use pyo3::types::{PyBytes, PyDict, PyList}; use serde_json::Value as JsonValue; use crate::params::{header_get_lax, parse_query, value_for_path_param}; @@ -353,35 +353,197 @@ pub async fn run_rsgi( } else { res }; - let (status, bytes, content_type) = match Python::with_gil(|py| -> PyResult<(u16, Vec, String)> { - let b = handler_out.bind(py); - if let Ok(s) = b.extract::() { - return Ok((200, s.into_bytes(), "text/plain; charset=utf-8".to_string())); + let mapped = match Python::with_gil(|py| map_handler_return(py, &handler_out)) { + Ok(m) => m, + Err(e) => { + return send_internal_error(&protocol, &method, &path, e).await; } - if let Ok(s) = b.extract::<&str>() { - return Ok((200, s.as_bytes().to_vec(), "text/plain; charset=utf-8".to_string())); + }; + match mapped { + HandlerMap::WithHeaders { status, body, headers } => { + response::send_with_headers(&protocol, status, &body, headers).await } - if let Ok(buf) = b.extract::>() { - return Ok((200, buf, "application/octet-stream".to_string())); + HandlerMap::Simple { status, body, content_type } => { + response::send_bytes(&protocol, status, &body, &content_type).await } - if let Ok(d) = b.downcast::() { - let st = d.get_item("status")?; - let bd = d.get_item("body")?; - if let (Some(sc), Some(body)) = (st, bd) { - if let (Ok(code), Ok(bstr)) = (sc.extract::(), body.str()) { - return Ok((code, bstr.to_string().into_bytes(), "text/plain; charset=utf-8".to_string())); + } +} + +/// Return value of a user handler, mapped to an HTTP body and headers. +fn map_handler_return(py: Python<'_>, out: &Py) -> PyResult { + let b = out.bind(py); + // Before `extract::`: some non-`str` objects may still coerce in edge cases; + // `Response` must be recognized first. + if is_oxyroute_response(py, b)? { + return structured_from_response_attrs(py, b); + } + if let Ok(s) = b.extract::() { + return Ok(HandlerMap::Simple { + status: 200, + body: s.into_bytes(), + content_type: "text/plain; charset=utf-8".to_string(), + }); + } + if let Ok(s) = b.extract::<&str>() { + return Ok(HandlerMap::Simple { + status: 200, + body: s.as_bytes().to_vec(), + content_type: "text/plain; charset=utf-8".to_string(), + }); + } + if let Ok(buf) = b.extract::>() { + return Ok(HandlerMap::Simple { + status: 200, + body: buf, + content_type: "application/octet-stream".to_string(), + }); + } + if let Ok(d) = b.downcast::() { + let h = d.get_item("headers")?; + let c = d.get_item("cookies")?; + let has_structured = h.is_some() || c.is_some(); + if has_structured { + if let (Some(st), Some(bd)) = (d.get_item("status")?, d.get_item("body")?) { + return structured_from_status_body( + py, + &st, + &bd, + d.get_item("headers")?, + d.get_item("cookies")?, + ); + } + } + let st = d.get_item("status")?; + let bd = d.get_item("body")?; + if let (Some(sc), Some(body)) = (st, bd) { + if let (Ok(code), Ok(bstr)) = (sc.extract::(), body.str()) { + return Ok(HandlerMap::Simple { + status: code, + body: bstr.to_string().into_bytes(), + content_type: "text/plain; charset=utf-8".to_string(), + }); + } + } + } + let jmod = py.import_bound("json")?; + let dumped = jmod.call_method1("dumps", (b.clone().unbind(),))?; + let s: String = dumped.extract()?; + Ok(HandlerMap::Simple { + status: 200, + body: s.into_bytes(), + content_type: "application/json; charset=utf-8".to_string(), + }) +} + +enum HandlerMap { + WithHeaders { + status: u16, + body: Vec, + headers: Vec<(String, String)>, + }, + Simple { + status: u16, + body: Vec, + content_type: String, + }, +} + +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` / + // `import oxyroute` from the shared library — ABI / import subtleties can differ. + if b.is_instance_of::() { + return Ok(false); + } + Ok(b.hasattr("status")? && b.hasattr("body")? && b.hasattr("headers")?) +} + +fn structured_from_response_attrs( + py: Python<'_>, + b: &Bound<'_, PyAny>, +) -> PyResult { + let st = b.getattr("status")?; + let body = b.getattr("body")?; + let headers = b.getattr("headers")?; + let cookies = b.getattr("cookies")?; + structured_from_status_body( + py, + &st, + &body, + Some(headers), + Some(cookies), + ) +} + +fn structured_from_status_body( + py: Python<'_>, + st: &Bound<'_, PyAny>, + body_val: &Bound<'_, PyAny>, + headers: Option>, + cookies: Option>, +) -> PyResult { + let status: u16 = st.extract()?; + let (body, default_ct) = value_to_bytes_and_ct(py, body_val)?; + let mut has_ct = false; + let mut pairs: Vec<(String, String)> = Vec::new(); + if let Some(h) = headers { + if !h.is_none() { + let d = h.downcast::()?; + for (k, v) in d.iter() { + let key: String = k.extract()?; + let val: String = v.extract()?; + if key.eq_ignore_ascii_case("content-type") { + has_ct = true; } + pairs.push((key, val)); } } - let jmod = py.import_bound("json")?; - let dumped = jmod.call_method1("dumps", (b.clone().unbind(),))?; - let s: String = dumped.extract()?; - Ok((200, s.into_bytes(), "application/json; charset=utf-8".to_string())) - }) { - Ok(x) => x, - Err(e) => { - return send_internal_error(&protocol, &method, &path, e).await; + } + if !has_ct { + pairs.insert(0, ("content-type".to_string(), default_ct)); + } + if let Some(c) = cookies { + if !c.is_none() { + for item in c.downcast::()?.iter() { + let s: String = item.extract()?; + pairs.push(("set-cookie".to_string(), s)); + } } - }; - response::send_bytes(&protocol, status, &bytes, &content_type).await + } + Ok(HandlerMap::WithHeaders { status, body, headers: pairs }) +} + +/// JSON body, etc. +fn value_to_bytes_and_ct(py: Python<'_>, b: &Bound<'_, PyAny>) -> PyResult<(Vec, String)> { + if b.is_none() { + return Ok(( + Vec::new(), + "text/plain; charset=utf-8".to_string(), + )); + } + if let Ok(s) = b.extract::() { + return Ok(( + s.into_bytes(), + "text/plain; charset=utf-8".to_string(), + )); + } + if let Ok(s) = b.extract::<&str>() { + return Ok(( + s.as_bytes().to_vec(), + "text/plain; charset=utf-8".to_string(), + )); + } + if let Ok(buf) = b.extract::>() { + return Ok(( + buf, + "application/octet-stream".to_string(), + )); + } + let jmod = py.import_bound("json")?; + let dumped = jmod.call_method1("dumps", (b.clone().unbind(),))?; + let s: String = dumped.extract()?; + Ok(( + s.into_bytes(), + "application/json; charset=utf-8".to_string(), + )) } diff --git a/src/response.rs b/src/response.rs index 81cfff1..deeab54 100644 --- a/src/response.rs +++ b/src/response.rs @@ -62,6 +62,62 @@ pub async fn send_bytes( }) } +/// RSGI `response_bytes` / `response_empty` with a full `[(name, value), ...]` header list. +pub async fn send_with_headers( + protocol: &Py, + status: u16, + body: &[u8], + headers: Vec<(String, String)>, +) -> PyResult { + if body.is_empty() { + return send_empty_with_header_pairs(protocol, status, headers).await; + } + Python::with_gil(|py| { + let p = protocol.bind(py); + let h = build_header_list_from_pairs(py, &headers)?; + p.getattr("response_bytes")? + .call1((u16::from(status), h, body))?; + Ok(pyo3::types::PyNone::get_bound(py).to_object(py)) + }) +} + +async fn send_empty_with_header_pairs( + protocol: &Py, + status: u16, + headers: Vec<(String, String)>, +) -> PyResult { + Python::with_gil(|py| { + let p = protocol.bind(py); + let h = build_header_list_from_pairs(py, &headers)?; + p.getattr("response_empty")? + .call1((u16::from(status), h))?; + Ok(pyo3::types::PyNone::get_bound(py).to_object(py)) + }) +} + +fn build_header_list_from_pairs<'py>( + py: Python<'py>, + pairs: &[(String, String)], +) -> PyResult> { + let out = PyList::empty_bound(py); + for (k, v) in pairs { + let name: String = if k.eq_ignore_ascii_case("set-cookie") { + "set-cookie".to_string() + } else { + k.to_ascii_lowercase() + }; + let pair = PyTuple::new_bound( + py, + [ + PyString::new_bound(py, &name), + PyString::new_bound(py, v.as_str()), + ], + ); + out.append(pair)?; + } + Ok(out) +} + fn build_headers_ct<'py>( py: Python<'py>, content_type: Option<&str>, diff --git a/tests/test_asgi.py b/tests/test_asgi.py index 5b10c7d..ff5c2d6 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -6,7 +6,7 @@ import httpx -from oxyroute import App +from oxyroute import App, Response def test_asgi_get_plain_text() -> None: @@ -40,3 +40,26 @@ async def _run() -> None: assert r.text == "p:7" asyncio.run(_run()) + + +def test_asgi_response_custom_headers_and_json_ct() -> None: + app = App() + + @app.get("/j") + def j() -> Response: + return Response( + body={"x": 1}, + status=201, + headers={"content-type": "application/json", "X-Trick": "z"}, + ) + + async def _run() -> None: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.get("/j") + assert r.status_code == 201, r.text + assert r.headers["x-trick"] == "z" + assert "application/json" in (r.headers.get("content-type") or "") + assert r.json() == {"x": 1} + + asyncio.run(_run())