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
69 changes: 42 additions & 27 deletions src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1341,27 +1341,11 @@ fn map_handler_return(py: Python<'_>, out: &Py<PyAny>) -> PyResult<HandlerMap> {
if is_oxyroute_response(py, b)? {
return structured_from_response_attrs(py, b);
}
if let Ok(s) = b.extract::<String>() {
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::<Vec<u8>>() {
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<u8>` coercion attempts below. pyo3 happily extracts any
// Python sequence of small ints (including `[]`) as `Vec<u8>`, 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::<PyDict>() {
let h = d.get_item("headers")?;
let c = d.get_item("cookies")?;
Expand All @@ -1380,14 +1364,45 @@ fn map_handler_return(py: Python<'_>, out: &Py<PyAny>) -> PyResult<HandlerMap> {
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::<u16>(), 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::<u16>() {
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::<PyList>() {
if let Ok(s) = b.extract::<String>() {
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::<Vec<u8>>() {
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(),))?;
Expand Down
90 changes: 90 additions & 0 deletions tests/test_handler_return_mapping.py
Original file line number Diff line number Diff line change
@@ -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())
Loading