Skip to content

Commit 82edcfb

Browse files
committed
Add docroot passthrough and properly test http connection scope
1 parent 73b61a6 commit 82edcfb

File tree

6 files changed

+218
-66
lines changed

6 files changed

+218
-66
lines changed

src/asgi/http.rs

Lines changed: 137 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,10 @@ pub struct HttpConnectionScope {
6464
state: Option<Py<PyDict>>,
6565
}
6666

67-
impl HttpConnectionScope {
68-
/// Create a new HttpConnectionScope from an http::Request
69-
pub fn from_request(request: &Request) -> Self {
67+
impl TryFrom<&Request> for HttpConnectionScope {
68+
type Error = PyErr;
69+
70+
fn try_from(request: &Request) -> Result<Self, Self::Error> {
7071
// Extract HTTP version
7172
let http_version = match request.version() {
7273
Version::HTTP_09 => HttpVersion::V1_0, // fallback for HTTP/0.9
@@ -78,7 +79,8 @@ impl HttpConnectionScope {
7879
};
7980

8081
// Extract method
81-
let method = HttpMethod::from(request.method().as_str());
82+
let method = request.method().try_into()
83+
.map_err(PyValueError::new_err)?;
8284

8385
// Extract scheme from URI or default to http
8486
let scheme = request
@@ -100,8 +102,11 @@ impl HttpConnectionScope {
100102
.unwrap_or("")
101103
.to_string();
102104

103-
// Extract root path (default to empty)
104-
let root_path = String::new();
105+
// Extract root path from DocumentRoot extension
106+
let root_path = request
107+
.document_root()
108+
.map(|doc_root| doc_root.path.to_string_lossy().to_string())
109+
.unwrap_or_default();
105110

106111
// Convert headers
107112
let headers: Vec<(String, String)> = request
@@ -130,7 +135,7 @@ impl HttpConnectionScope {
130135
(None, None)
131136
};
132137

133-
HttpConnectionScope {
138+
Ok(HttpConnectionScope {
134139
http_version,
135140
method,
136141
scheme,
@@ -142,7 +147,7 @@ impl HttpConnectionScope {
142147
client,
143148
server,
144149
state: None,
145-
}
150+
})
146151
}
147152
}
148153

@@ -322,7 +327,7 @@ impl<'py> FromPyObject<'py> for HttpSendMessage {
322327
.ok_or_else(|| {
323328
PyValueError::new_err("Missing 'headers' key in HTTP response start message")
324329
})?;
325-
330+
326331
// Convert headers from list of lists to vec of tuples
327332
let mut headers: Vec<(String, String)> = Vec::new();
328333
if let Ok(headers_list) = headers_py.downcast::<pyo3::types::PyList>() {
@@ -331,20 +336,20 @@ impl<'py> FromPyObject<'py> for HttpSendMessage {
331336
if header_pair.len() == 2 {
332337
let name = header_pair.get_item(0)?;
333338
let value = header_pair.get_item(1)?;
334-
339+
335340
// Convert bytes to string
336341
let name_str = if let Ok(bytes) = name.downcast::<pyo3::types::PyBytes>() {
337342
String::from_utf8_lossy(bytes.as_bytes()).to_string()
338343
} else {
339344
name.extract::<String>()?
340345
};
341-
346+
342347
let value_str = if let Ok(bytes) = value.downcast::<pyo3::types::PyBytes>() {
343348
String::from_utf8_lossy(bytes.as_bytes()).to_string()
344349
} else {
345350
value.extract::<String>()?
346351
};
347-
352+
348353
headers.push((name_str, value_str));
349354
}
350355
}
@@ -390,6 +395,9 @@ pub enum HttpSendException {
390395
#[cfg(test)]
391396
mod tests {
392397
use super::*;
398+
use http_handler::{RequestExt, extensions::DocumentRoot};
399+
use http_handler::{Method, Version, request::Builder};
400+
use std::{net::{IpAddr, Ipv4Addr, SocketAddr}, path::PathBuf};
393401

394402
macro_rules! dict_get {
395403
($dict:expr, $key:expr) => {
@@ -408,6 +416,119 @@ mod tests {
408416
};
409417
}
410418

419+
#[test]
420+
fn test_http_connection_scope_from_request() {
421+
// Create a test request with various headers and extensions
422+
let mut request = Builder::new()
423+
.method(Method::POST)
424+
.uri("https://example.com:8443/api/v1/users?sort=name&limit=10")
425+
.header("content-type", "application/json")
426+
.header("authorization", "Bearer token123")
427+
.header("user-agent", "test-client/1.0")
428+
.header("x-custom-header", "custom-value")
429+
.body(bytes::BytesMut::from("request body"))
430+
.unwrap();
431+
432+
// Set socket info extension
433+
let local_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8443);
434+
let remote_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)), 12345);
435+
request.set_socket_info(http_handler::extensions::SocketInfo::new(
436+
Some(local_addr),
437+
Some(remote_addr),
438+
));
439+
440+
// Set document root extension
441+
let doc_root = PathBuf::from("/var/www/html");
442+
request.set_document_root(DocumentRoot { path: doc_root.clone() });
443+
444+
// Convert to ASGI scope
445+
let scope: HttpConnectionScope = (&request).try_into()
446+
.expect("Failed to convert request to HttpConnectionScope");
447+
448+
// Verify HTTP version
449+
assert_eq!(scope.http_version, HttpVersion::V1_1);
450+
451+
// Verify method
452+
assert_eq!(scope.method, HttpMethod::Post);
453+
454+
// Verify scheme
455+
assert_eq!(scope.scheme, "https");
456+
457+
// Verify path
458+
assert_eq!(scope.path, "/api/v1/users");
459+
460+
// Verify raw_path (should be same as path in this implementation)
461+
assert_eq!(scope.raw_path, "/api/v1/users");
462+
463+
// Verify query_string
464+
assert_eq!(scope.query_string, "sort=name&limit=10");
465+
466+
// Verify root_path from DocumentRoot extension
467+
assert_eq!(scope.root_path, doc_root.to_string_lossy());
468+
469+
// Verify headers (should be lowercased)
470+
let expected_headers = vec![
471+
("content-type".to_string(), "application/json".to_string()),
472+
("authorization".to_string(), "Bearer token123".to_string()),
473+
("user-agent".to_string(), "test-client/1.0".to_string()),
474+
("x-custom-header".to_string(), "custom-value".to_string()),
475+
];
476+
assert_eq!(scope.headers, expected_headers);
477+
478+
// Verify client socket info
479+
assert_eq!(scope.client, Some(("192.168.1.100".to_string(), 12345)));
480+
481+
// Verify server socket info
482+
assert_eq!(scope.server, Some(("127.0.0.1".to_string(), 8443)));
483+
484+
// Verify state is None (not set)
485+
assert!(scope.state.is_none());
486+
}
487+
488+
#[test]
489+
fn test_http_connection_scope_from_request_minimal() {
490+
// Test with minimal request (no extensions, no headers)
491+
let request = Builder::new()
492+
.method(Method::GET)
493+
.uri("/")
494+
.body(bytes::BytesMut::new())
495+
.unwrap();
496+
497+
let scope: HttpConnectionScope = (&request).try_into()
498+
.expect("Failed to convert request to HttpConnectionScope");
499+
500+
assert_eq!(scope.http_version, HttpVersion::V1_1);
501+
assert_eq!(scope.method, HttpMethod::Get);
502+
assert_eq!(scope.scheme, "http"); // default scheme
503+
assert_eq!(scope.path, "/");
504+
assert_eq!(scope.raw_path, "/");
505+
assert_eq!(scope.query_string, "");
506+
assert_eq!(scope.root_path, ""); // no DocumentRoot extension
507+
assert_eq!(scope.headers, vec![]); // no headers
508+
assert_eq!(scope.client, None); // no socket info
509+
assert_eq!(scope.server, None); // no socket info
510+
assert!(scope.state.is_none());
511+
}
512+
513+
#[test]
514+
fn test_http_connection_scope_from_request_http2() {
515+
// Test HTTP/2 version handling
516+
let request = Builder::new()
517+
.method(Method::PUT)
518+
.uri("http://api.example.com/resource/123")
519+
.version(Version::HTTP_2)
520+
.body(bytes::BytesMut::new())
521+
.unwrap();
522+
523+
let scope: HttpConnectionScope = (&request).try_into()
524+
.expect("Failed to convert request to HttpConnectionScope");
525+
526+
assert_eq!(scope.http_version, HttpVersion::V2_0);
527+
assert_eq!(scope.method, HttpMethod::Put);
528+
assert_eq!(scope.scheme, "http");
529+
assert_eq!(scope.path, "/resource/123");
530+
}
531+
411532
#[test]
412533
fn test_http_connection_scope_into_pyobject() {
413534
Python::with_gil(|py| {
@@ -478,12 +599,10 @@ mod tests {
478599
let dict = PyDict::new(py);
479600
dict.set_item("type", "http.response.start").unwrap();
480601
dict.set_item("status", 200).unwrap();
481-
dict
482-
.set_item(
483-
"headers",
484-
vec![("content-type".to_string(), "text/plain".to_string())],
485-
)
486-
.unwrap();
602+
603+
// Headers should be a list of lists in ASGI format
604+
let headers = vec![vec!["content-type", "text/plain"]];
605+
dict.set_item("headers", headers).unwrap();
487606
dict.set_item("trailers", false).unwrap();
488607

489608
let message: HttpSendMessage = dict.extract().unwrap();

src/asgi/http_method.rs

Lines changed: 23 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,11 @@ pub enum HttpMethod {
2323
impl<'py> FromPyObject<'py> for HttpMethod {
2424
fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult<Self> {
2525
let method: &str = ob.extract()?;
26-
match method.to_uppercase().as_str() {
27-
"GET" => Ok(HttpMethod::Get),
28-
"POST" => Ok(HttpMethod::Post),
29-
"PUT" => Ok(HttpMethod::Put),
30-
"DELETE" => Ok(HttpMethod::Delete),
31-
"PATCH" => Ok(HttpMethod::Patch),
32-
"HEAD" => Ok(HttpMethod::Head),
33-
"OPTIONS" => Ok(HttpMethod::Options),
34-
"TRACE" => Ok(HttpMethod::Trace),
35-
"CONNECT" => Ok(HttpMethod::Connect),
36-
_ => Err(PyValueError::new_err(format!(
37-
"Invalid HTTP method: {method}"
38-
))),
39-
}
26+
method
27+
.to_uppercase()
28+
.as_str()
29+
.parse()
30+
.map_err(PyValueError::new_err)
4031
}
4132
}
4233

@@ -83,16 +74,24 @@ impl TryFrom<String> for HttpMethod {
8374
type Error = String;
8475

8576
fn try_from(method: String) -> Result<HttpMethod, Self::Error> {
86-
match method.to_uppercase().as_str() {
87-
"GET" => Ok(HttpMethod::Get),
88-
"POST" => Ok(HttpMethod::Post),
89-
"PUT" => Ok(HttpMethod::Put),
90-
"DELETE" => Ok(HttpMethod::Delete),
91-
"PATCH" => Ok(HttpMethod::Patch),
92-
"HEAD" => Ok(HttpMethod::Head),
93-
"OPTIONS" => Ok(HttpMethod::Options),
94-
"TRACE" => Ok(HttpMethod::Trace),
95-
"CONNECT" => Ok(HttpMethod::Connect),
77+
method.as_str().parse()
78+
}
79+
}
80+
81+
impl TryFrom<&http_handler::Method> for HttpMethod {
82+
type Error = String;
83+
84+
fn try_from(method: &http_handler::Method) -> Result<HttpMethod, Self::Error> {
85+
match *method {
86+
http_handler::Method::GET => Ok(HttpMethod::Get),
87+
http_handler::Method::POST => Ok(HttpMethod::Post),
88+
http_handler::Method::PUT => Ok(HttpMethod::Put),
89+
http_handler::Method::DELETE => Ok(HttpMethod::Delete),
90+
http_handler::Method::PATCH => Ok(HttpMethod::Patch),
91+
http_handler::Method::HEAD => Ok(HttpMethod::Head),
92+
http_handler::Method::OPTIONS => Ok(HttpMethod::Options),
93+
http_handler::Method::TRACE => Ok(HttpMethod::Trace),
94+
http_handler::Method::CONNECT => Ok(HttpMethod::Connect),
9695
_ => Err(format!("Invalid HTTP method: {method}")),
9796
}
9897
}
@@ -114,23 +113,6 @@ impl From<HttpMethod> for String {
114113
}
115114
}
116115

117-
impl From<&str> for HttpMethod {
118-
fn from(method: &str) -> Self {
119-
match method.to_uppercase().as_str() {
120-
"GET" => HttpMethod::Get,
121-
"POST" => HttpMethod::Post,
122-
"PUT" => HttpMethod::Put,
123-
"DELETE" => HttpMethod::Delete,
124-
"PATCH" => HttpMethod::Patch,
125-
"HEAD" => HttpMethod::Head,
126-
"OPTIONS" => HttpMethod::Options,
127-
"TRACE" => HttpMethod::Trace,
128-
"CONNECT" => HttpMethod::Connect,
129-
_ => HttpMethod::Get, // Default to GET for unknown methods
130-
}
131-
}
132-
}
133-
134116
#[cfg(test)]
135117
mod tests {
136118
use super::*;

src/asgi/mod.rs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,15 @@ pub async fn execute_asgi_http_scope(
3131
request: Request,
3232
) -> PyResult<Response> {
3333
// Create the ASGI scope from the HTTP request
34-
let scope = HttpConnectionScope::from_request(&request);
34+
let scope: HttpConnectionScope = (&request).try_into()?;
3535

3636
// Create channels for ASGI communication
3737
let (rx_receiver, rx) = Receiver::http();
3838
let (tx_sender, mut tx) = Sender::http();
3939

4040
// Channel to receive the response data
4141
let (response_tx, response_rx) = oneshot::channel::<(u16, Vec<(String, String)>, Vec<u8>)>();
42-
42+
4343
// Channel to signal Python execution completion
4444
let (python_done_tx, python_done_rx) = oneshot::channel::<Result<(), String>>();
4545

@@ -140,11 +140,14 @@ pub async fn execute_asgi_http_scope(
140140

141141
// Run the coroutine
142142
let result = loop_.call_method1("run_until_complete", (coroutine,));
143-
143+
144144
// Close the loop
145145
let _ = loop_.call_method0("close");
146-
147-
// Send the result
146+
147+
// Send the result after a small delay to allow response to be processed
148+
// This ensures the response handler has time to receive messages from the ASGI app
149+
std::thread::sleep(std::time::Duration::from_millis(10));
150+
148151
let _ = python_done_tx.send(result.map(|_| ()).map_err(|e| {
149152
format!("Failed to run ASGI coroutine: {}", e)
150153
}));
@@ -160,6 +163,7 @@ pub async fn execute_asgi_http_scope(
160163
match python_result {
161164
Ok(Ok(())) => {
162165
// Python completed but no response was sent
166+
eprintln!("ASGI app completed without sending response");
163167
Err(pyo3::exceptions::PyRuntimeError::new_err("ASGI app completed without sending response"))
164168
}
165169
Ok(Err(e)) => {
@@ -173,7 +177,7 @@ pub async fn execute_asgi_http_scope(
173177
}
174178
}
175179
};
176-
180+
177181
let (status, headers, body) = result?;
178182

179183
// Clean up the response task

src/lib.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use std::path::PathBuf;
1515

1616
#[cfg(feature = "napi-support")]
1717
use http_handler::napi::{Request as NapiRequest, Response as NapiResponse};
18-
use http_handler::{Handler, Request, Response};
18+
use http_handler::{Handler, Request, Response, RequestExt, extensions::DocumentRoot};
1919
#[cfg(feature = "napi-support")]
2020
#[allow(unused_imports)]
2121
use http_rewriter::napi::Rewriter;
@@ -358,6 +358,12 @@ impl Handler for PythonRequestTask {
358358
})
359359
.map_err(HandlerError::PythonError)?;
360360

361+
// Set the document root extension on the request
362+
let mut request = request;
363+
request.set_document_root(DocumentRoot {
364+
path: docroot,
365+
});
366+
361367
// Execute the ASGI application
362368
execute_asgi_http_scope(func, request)
363369
.await

0 commit comments

Comments
 (0)