Skip to content

Commit 962c683

Browse files
committed
feat: add support for streaming responses
1 parent 02d1ce7 commit 962c683

File tree

2 files changed

+233
-22
lines changed

2 files changed

+233
-22
lines changed

Diff for: integration_tests/base_routes.py

+129
Original file line numberDiff line numberDiff line change
@@ -1082,6 +1082,135 @@ def create_item(request, body: CreateItemBody, query: CreateItemQueryParamsParam
10821082
return CreateItemResponse(success=True, items_changed=2)
10831083

10841084

1085+
# --- Streaming responses ---
1086+
1087+
@app.get("/stream/sync")
1088+
def sync_stream():
1089+
def number_generator():
1090+
for i in range(5):
1091+
yield f"Chunk {i}\n".encode()
1092+
1093+
return Response(
1094+
status_code=200,
1095+
headers={"Content-Type": "text/plain"},
1096+
description=number_generator()
1097+
)
1098+
1099+
@app.get("/stream/async")
1100+
async def async_stream():
1101+
async def async_generator():
1102+
import asyncio
1103+
for i in range(5):
1104+
await asyncio.sleep(1) # Simulate async work
1105+
yield f"Async Chunk {i}\n".encode()
1106+
1107+
return Response(
1108+
status_code=200,
1109+
headers={"Content-Type": "text/plain"},
1110+
description=async_generator()
1111+
)
1112+
1113+
@app.get("/stream/mixed")
1114+
async def mixed_stream():
1115+
async def mixed_generator():
1116+
import asyncio
1117+
# Binary data
1118+
yield b"Binary chunk\n"
1119+
await asyncio.sleep(0.5)
1120+
1121+
# String data
1122+
yield "String chunk\n".encode()
1123+
await asyncio.sleep(0.5)
1124+
1125+
# Integer data
1126+
yield str(42).encode() + b"\n"
1127+
await asyncio.sleep(0.5)
1128+
1129+
# JSON data
1130+
import json
1131+
data = {"message": "JSON chunk", "number": 123}
1132+
yield json.dumps(data).encode() + b"\n"
1133+
1134+
return Response(
1135+
status_code=200,
1136+
headers={"Content-Type": "text/plain"},
1137+
description=mixed_generator()
1138+
)
1139+
1140+
@app.get("/stream/events")
1141+
async def server_sent_events():
1142+
async def event_generator():
1143+
import asyncio
1144+
import json
1145+
import time
1146+
1147+
# Regular event
1148+
yield f"event: message\ndata: {json.dumps({'time': time.time(), 'type': 'start'})}\n\n".encode()
1149+
await asyncio.sleep(1)
1150+
1151+
# Event with ID
1152+
yield f"id: 1\nevent: update\ndata: {json.dumps({'progress': 50})}\n\n".encode()
1153+
await asyncio.sleep(1)
1154+
1155+
# Multiple data lines
1156+
data = json.dumps({'status': 'complete', 'results': [1, 2, 3]}, indent=2)
1157+
yield f"event: complete\ndata: {data}\n\n".encode()
1158+
1159+
return Response(
1160+
status_code=200,
1161+
headers={
1162+
"Content-Type": "text/event-stream",
1163+
"Cache-Control": "no-cache",
1164+
"Connection": "keep-alive"
1165+
},
1166+
description=event_generator()
1167+
)
1168+
1169+
@app.get("/stream/large-file")
1170+
async def stream_large_file():
1171+
async def file_generator():
1172+
# Simulate streaming a large file in chunks
1173+
chunk_size = 1024 # 1KB chunks
1174+
total_size = 10 * chunk_size # 10KB total
1175+
1176+
for offset in range(0, total_size, chunk_size):
1177+
# Simulate reading file chunk
1178+
chunk = b"X" * min(chunk_size, total_size - offset)
1179+
yield chunk
1180+
1181+
return Response(
1182+
status_code=200,
1183+
headers={
1184+
"Content-Type": "application/octet-stream",
1185+
"Content-Disposition": "attachment; filename=large-file.bin"
1186+
},
1187+
description=file_generator()
1188+
)
1189+
1190+
@app.get("/stream/csv")
1191+
async def stream_csv():
1192+
async def csv_generator():
1193+
# CSV header
1194+
yield "id,name,value\n".encode()
1195+
1196+
import asyncio
1197+
import random
1198+
1199+
# Generate rows
1200+
for i in range(5):
1201+
await asyncio.sleep(0.5) # Simulate data processing
1202+
row = f"{i},item-{i},{random.randint(1, 100)}\n"
1203+
yield row.encode()
1204+
1205+
return Response(
1206+
status_code=200,
1207+
headers={
1208+
"Content-Type": "text/csv",
1209+
"Content-Disposition": "attachment; filename=data.csv"
1210+
},
1211+
description=csv_generator()
1212+
)
1213+
10851214
def main():
10861215
app.set_response_header("server", "robyn")
10871216
app.serve_directory(

Diff for: src/types/response.rs

+104-22
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,92 @@
11
use actix_http::{body::BoxBody, StatusCode};
2-
use actix_web::{HttpRequest, HttpResponse, HttpResponseBuilder, Responder};
2+
use actix_web::{HttpRequest, HttpResponse, HttpResponseBuilder, Responder, Error, web::Bytes};
33
use pyo3::{
44
exceptions::PyIOError,
55
prelude::*,
6-
types::{PyBytes, PyDict},
6+
types::{PyBytes, PyDict, PyList},
77
};
8+
use futures::stream::Stream;
9+
use futures_util::StreamExt;
10+
use std::pin::Pin;
811

912
use crate::io_helpers::{apply_hashmap_headers, read_file};
1013
use crate::types::{check_body_type, check_description_type, get_description_from_pyobject};
1114

1215
use super::headers::Headers;
1316

14-
#[derive(Debug, Clone, FromPyObject)]
17+
#[derive(Debug, Clone)]
18+
pub enum ResponseBody {
19+
Static(Vec<u8>),
20+
Streaming(Vec<Vec<u8>>),
21+
}
22+
23+
#[derive(Debug, Clone)]
1524
pub struct Response {
1625
pub status_code: u16,
1726
pub response_type: String,
1827
pub headers: Headers,
19-
// https://pyo3.rs/v0.19.2/function.html?highlight=from_py_#per-argument-options
20-
#[pyo3(from_py_with = "get_description_from_pyobject")]
21-
pub description: Vec<u8>,
28+
pub body: ResponseBody,
2229
pub file_path: Option<String>,
2330
}
2431

32+
impl<'a> FromPyObject<'a> for Response {
33+
fn extract(ob: &'a PyAny) -> PyResult<Self> {
34+
let status_code = ob.getattr("status_code")?.extract()?;
35+
let response_type = ob.getattr("response_type")?.extract()?;
36+
let headers = ob.getattr("headers")?.extract()?;
37+
let description = ob.getattr("description")?;
38+
let file_path = ob.getattr("file_path")?.extract()?;
39+
40+
let body = if let Ok(iter) = description.iter() {
41+
let mut chunks = Vec::new();
42+
for item in iter {
43+
let item = item?;
44+
let chunk = if item.is_instance_of::<pyo3::types::PyBytes>() {
45+
item.extract::<Vec<u8>>()?
46+
} else if item.is_instance_of::<pyo3::types::PyString>() {
47+
item.extract::<String>()?.into_bytes()
48+
} else if item.is_instance_of::<pyo3::types::PyInt>() {
49+
item.extract::<i64>()?.to_string().into_bytes()
50+
} else {
51+
return Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>(
52+
"Stream items must be bytes, str, or int"
53+
));
54+
};
55+
chunks.push(chunk);
56+
}
57+
ResponseBody::Streaming(chunks)
58+
} else {
59+
ResponseBody::Static(get_description_from_pyobject(description)?)
60+
};
61+
62+
Ok(Response {
63+
status_code,
64+
response_type,
65+
headers,
66+
body,
67+
file_path,
68+
})
69+
}
70+
}
71+
2572
impl Responder for Response {
2673
type Body = BoxBody;
2774

2875
fn respond_to(self, _req: &HttpRequest) -> HttpResponse<Self::Body> {
2976
let mut response_builder =
3077
HttpResponseBuilder::new(StatusCode::from_u16(self.status_code).unwrap());
3178
apply_hashmap_headers(&mut response_builder, &self.headers);
32-
response_builder.body(self.description)
79+
80+
match self.body {
81+
ResponseBody::Static(data) => response_builder.body(data),
82+
ResponseBody::Streaming(chunks) => {
83+
let stream = Box::pin(
84+
futures::stream::iter(chunks.into_iter())
85+
.map(|chunk| Ok::<Bytes, Error>(Bytes::from(chunk)))
86+
) as Pin<Box<dyn Stream<Item = Result<Bytes, Error>>>>;
87+
response_builder.streaming(stream)
88+
}
89+
}
3390
}
3491
}
3592

@@ -44,7 +101,7 @@ impl Response {
44101
status_code: 404,
45102
response_type: "text".to_string(),
46103
headers,
47-
description: "Not found".to_owned().into_bytes(),
104+
body: ResponseBody::Static("Not found".to_owned().into_bytes()),
48105
file_path: None,
49106
}
50107
}
@@ -59,7 +116,7 @@ impl Response {
59116
status_code: 500,
60117
response_type: "text".to_string(),
61118
headers,
62-
description: "Internal server error".to_owned().into_bytes(),
119+
body: ResponseBody::Static("Internal server error".to_owned().into_bytes()),
63120
file_path: None,
64121
}
65122
}
@@ -68,11 +125,21 @@ impl Response {
68125
impl ToPyObject for Response {
69126
fn to_object(&self, py: Python) -> PyObject {
70127
let headers = self.headers.clone().into_py(py).extract(py).unwrap();
71-
// The description should only be either string or binary.
72-
// it should raise an exception otherwise
73-
let description = match String::from_utf8(self.description.to_vec()) {
74-
Ok(description) => description.to_object(py),
75-
Err(_) => PyBytes::new(py, &self.description.to_vec()).into(),
128+
129+
let description = match &self.body {
130+
ResponseBody::Static(data) => {
131+
match String::from_utf8(data.to_vec()) {
132+
Ok(description) => description.to_object(py),
133+
Err(_) => PyBytes::new(py, data).into(),
134+
}
135+
},
136+
ResponseBody::Streaming(chunks) => {
137+
let list = PyList::empty(py);
138+
for chunk in chunks {
139+
list.append(PyBytes::new(py, chunk)).unwrap();
140+
}
141+
list.to_object(py)
142+
}
76143
};
77144

78145
let response = PyResponse {
@@ -111,15 +178,22 @@ impl PyResponse {
111178
headers: &PyAny,
112179
description: Py<PyAny>,
113180
) -> PyResult<Self> {
114-
check_body_type(py, &description)?;
181+
// Check if description is an iterator/generator
182+
let is_stream = Python::with_gil(|py| {
183+
description.as_ref(py).iter().is_ok()
184+
});
185+
186+
if is_stream {
187+
// For streaming responses, we don't need to check body type
188+
// as we'll validate each chunk when it's yielded
189+
} else {
190+
check_body_type(py, &description)?;
191+
}
115192

116193
let headers_output: Py<Headers> = if let Ok(headers_dict) = headers.downcast::<PyDict>() {
117-
// Here you'd have logic to create a Headers instance from a PyDict
118-
// For simplicity, let's assume you have a method `from_dict` on Headers for this
119-
let headers = Headers::new(Some(headers_dict)); // Hypothetical method
194+
let headers = Headers::new(Some(headers_dict));
120195
Py::new(py, headers)?
121196
} else if let Ok(headers) = headers.extract::<Py<Headers>>() {
122-
// If it's already a Py<Headers>, use it directly
123197
headers
124198
} else {
125199
return Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>(
@@ -129,8 +203,7 @@ impl PyResponse {
129203

130204
Ok(Self {
131205
status_code,
132-
// we should be handling based on headers but works for now
133-
response_type: "text".to_string(),
206+
response_type: if is_stream { "stream".to_string() } else { "text".to_string() },
134207
headers: headers_output,
135208
description,
136209
file_path: None,
@@ -139,7 +212,16 @@ impl PyResponse {
139212

140213
#[setter]
141214
pub fn set_description(&mut self, py: Python, description: Py<PyAny>) -> PyResult<()> {
142-
check_description_type(py, &description)?;
215+
// Check if description is an iterator/generator
216+
let is_stream = description.as_ref(py).iter().is_ok();
217+
218+
if is_stream {
219+
self.response_type = "stream".to_string();
220+
} else {
221+
check_description_type(py, &description)?;
222+
self.response_type = "text".to_string();
223+
}
224+
143225
self.description = description;
144226
Ok(())
145227
}

0 commit comments

Comments
 (0)