diff --git a/src/dispatch.rs b/src/dispatch.rs index 0abc15a..cea80bd 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -1341,27 +1341,11 @@ fn map_handler_return(py: Python<'_>, out: &Py) -> PyResult { 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: SimpleBody::Owned(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: SimpleBody::Owned(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: SimpleBody::Owned(buf), - content_type: "application/octet-stream".to_string(), - }); - } + // `dict` / `list` are JSON payloads by convention, not raw byte sequences โ€” this must run + // *before* the `String` / `Vec` coercion attempts below. pyo3 happily extracts any + // Python sequence of small ints (including `[]`) as `Vec`, so without this a JSON + // handler return like `[]` or `[1, 2, 3]` was silently reinterpreted as raw bytes with + // `content-type: application/octet-stream` instead of being JSON-serialized (issue #211). if let Ok(d) = b.downcast::() { let h = d.get_item("headers")?; let c = d.get_item("cookies")?; @@ -1380,14 +1364,45 @@ fn map_handler_return(py: Python<'_>, out: &Py) -> PyResult { 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: SimpleBody::Owned(bstr.to_string().into_bytes()), - content_type: "text/plain; charset=utf-8".to_string(), - }); + // Require a plausible HTTP status code (100-599, RFC 9110 ยง15) before treating this + // dict as a structured response โ€” a plain JSON payload that happens to also have + // "status" and "body" keys (e.g. `{"status": 1, "body": "x"}`) must round-trip as + // data, not be reinterpreted as an HTTP response with status 1 (issue #211). + if let Ok(code) = sc.extract::() { + if (100..=599).contains(&code) { + if let Ok(bstr) = body.str() { + return Ok(HandlerMap::Simple { + status: code, + body: SimpleBody::Owned(bstr.to_string().into_bytes()), + content_type: "text/plain; charset=utf-8".to_string(), + }); + } + } } } + // Not a structured response shape โ€” fall through to JSON serialization below. + } else if !b.is_instance_of::() { + if let Ok(s) = b.extract::() { + return Ok(HandlerMap::Simple { + status: 200, + body: SimpleBody::Owned(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: SimpleBody::Owned(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: SimpleBody::Owned(buf), + content_type: "application/octet-stream".to_string(), + }); + } } let jmod = py.import("json")?; let dumped = jmod.call_method1("dumps", (b.clone().unbind(),))?; diff --git a/tests/test_handler_return_mapping.py b/tests/test_handler_return_mapping.py new file mode 100644 index 0000000..601a3b5 --- /dev/null +++ b/tests/test_handler_return_mapping.py @@ -0,0 +1,90 @@ +"""Handler return-value -> HTTP response mapping (issue #211). + +``list``/``dict`` handler returns must always be JSON-serialized, never misread as raw +bytes; a dict that merely happens to contain "status" and "body" keys as ordinary data +must round-trip as JSON unless "status" is a plausible HTTP status code. +""" + +from __future__ import annotations + +import asyncio + +import httpx +from oxyroute import App +from oxyroute.testing import asgi_test_app + + +def _client(app: App) -> httpx.AsyncClient: + transport = httpx.ASGITransport(app=asgi_test_app(app)) + return httpx.AsyncClient(transport=transport, base_url="http://test") + + +def test_empty_list_is_json_not_octet_stream() -> None: + app = App() + + @app.get("/empty") + def empty() -> list: + return [] + + async def _run() -> None: + async with _client(app) as c: + r = await c.get("/empty") + assert r.status_code == 200 + assert r.headers.get("content-type", "").startswith("application/json") + assert r.json() == [] + + asyncio.run(_run()) + + +def test_int_list_is_json_not_raw_bytes() -> None: + app = App() + + @app.get("/ids") + def ids() -> list: + return [1, 2, 3] + + async def _run() -> None: + async with _client(app) as c: + r = await c.get("/ids") + assert r.status_code == 200 + assert r.headers.get("content-type", "").startswith("application/json") + assert r.json() == [1, 2, 3] + + asyncio.run(_run()) + + +def test_dict_with_out_of_range_status_roundtrips_as_data() -> None: + """A dict with "status"/"body" keys that isn't a plausible HTTP status (100-599) + must not be reinterpreted as a structured response.""" + app = App() + + @app.get("/data") + def data() -> dict: + return {"status": 1, "body": "x"} + + async def _run() -> None: + async with _client(app) as c: + r = await c.get("/data") + assert r.status_code == 200 + assert r.headers.get("content-type", "").startswith("application/json") + assert r.json() == {"status": 1, "body": "x"} + + asyncio.run(_run()) + + +def test_dict_with_valid_status_and_body_is_still_a_structured_response() -> None: + """Existing, documented shorthand: a dict with a real HTTP status code + body sets + the response status (docs/usage.md).""" + app = App() + + @app.get("/teapot") + def teapot() -> dict: + return {"status": 418, "body": "I'm a teapot"} + + async def _run() -> None: + async with _client(app) as c: + r = await c.get("/teapot") + assert r.status_code == 418 + assert r.text == "I'm a teapot" + + asyncio.run(_run())