1use std::{
17 collections::{HashMap, hash_map::DefaultHasher},
18 fs::File,
19 hash::{Hash, Hasher},
20 io::copy,
21 path::Path,
22 time::Duration,
23};
24
25use bytes::Bytes;
26use nautilus_core::{
27 collections::into_ustr_vec,
28 python::{to_pyruntime_err, to_pytype_err, to_pyvalue_err},
29};
30use pyo3::{create_exception, exceptions::PyException, prelude::*, types::PyDict};
31use reqwest::blocking::Client;
32
33use crate::{
34 http::{HttpClient, HttpClientError, HttpMethod, HttpResponse, HttpStatus},
35 ratelimiter::quota::Quota,
36};
37
38create_exception!(network, HttpError, PyException);
40
41create_exception!(network, HttpTimeoutError, PyException);
43
44create_exception!(network, HttpInvalidProxyError, PyException);
46
47create_exception!(network, HttpClientBuildError, PyException);
49
50impl HttpClientError {
51 #[must_use]
52 pub fn into_py_err(self) -> PyErr {
53 match self {
54 Self::Error(e) => PyErr::new::<HttpError, _>(e),
55 Self::TimeoutError(e) => PyErr::new::<HttpTimeoutError, _>(e),
56 Self::InvalidProxy(e) => PyErr::new::<HttpInvalidProxyError, _>(e),
57 Self::ClientBuildError(e) => PyErr::new::<HttpClientBuildError, _>(e),
58 }
59 }
60}
61
62#[pymethods]
63#[pyo3_stub_gen::derive::gen_stub_pymethods]
64impl HttpMethod {
65 #[expect(
66 clippy::cast_possible_wrap,
67 reason = "Python __hash__ requires isize; wrapping is the standard convention"
68 )]
69 fn __hash__(&self) -> isize {
70 let mut h = DefaultHasher::new();
71 self.hash(&mut h);
72 h.finish() as isize
73 }
74}
75
76#[pymethods]
77#[pyo3_stub_gen::derive::gen_stub_pymethods]
78impl HttpResponse {
79 #[new]
84 pub fn py_new(status: u16, body: Vec<u8>) -> PyResult<Self> {
85 Ok(Self {
86 status: HttpStatus::try_from(status).map_err(to_pyvalue_err)?,
87 headers: HashMap::new(),
88 body: Bytes::from(body),
89 })
90 }
91
92 #[getter]
93 #[pyo3(name = "status")]
94 pub const fn py_status(&self) -> u16 {
95 self.status.as_u16()
96 }
97
98 #[getter]
99 #[pyo3(name = "headers")]
100 pub fn py_headers(&self) -> HashMap<String, String> {
101 self.headers.clone()
102 }
103
104 #[getter]
105 #[pyo3(name = "body")]
106 #[gen_stub(override_return_type(type_repr = "bytes"))]
107 pub fn py_body(&self) -> &[u8] {
108 self.body.as_ref()
109 }
110}
111
112#[pymethods]
113#[pyo3_stub_gen::derive::gen_stub_pymethods]
114impl HttpClient {
115 #[new]
125 #[pyo3(signature = (default_headers=HashMap::new(), header_keys=Vec::new(), keyed_quotas=Vec::new(), default_quota=None, timeout_secs=None, proxy_url=None))]
126 pub fn py_new(
127 default_headers: HashMap<String, String>,
128 header_keys: Vec<String>,
129 keyed_quotas: Vec<(String, Quota)>,
130 default_quota: Option<Quota>,
131 timeout_secs: Option<u64>,
132 proxy_url: Option<String>,
133 ) -> PyResult<Self> {
134 Self::new(
135 default_headers,
136 header_keys,
137 keyed_quotas,
138 default_quota,
139 timeout_secs,
140 proxy_url,
141 )
142 .map_err(HttpClientError::into_py_err)
143 }
144
145 #[expect(clippy::too_many_arguments)]
151 #[pyo3(name = "request")]
152 #[pyo3(signature = (method, url, params=None, headers=None, body=None, keys=None, timeout_secs=None))]
153 fn py_request<'py>(
154 &self,
155 method: HttpMethod,
156 url: String,
157 params: Option<&Bound<'_, PyAny>>,
158 headers: Option<HashMap<String, String>>,
159 body: Option<Vec<u8>>,
160 keys: Option<Vec<String>>,
161 timeout_secs: Option<u64>,
162 py: Python<'py>,
163 ) -> PyResult<Bound<'py, PyAny>> {
164 let client = self.client.clone();
165 let rate_limiter = self.rate_limiter.clone();
166 let params = params_to_hashmap(params)?;
167
168 pyo3_async_runtimes::tokio::future_into_py(py, async move {
169 let keys = keys.map(into_ustr_vec);
170 rate_limiter.await_keys_ready(keys.as_deref()).await;
171 client
172 .send_request(
173 method.into(),
174 url,
175 params.as_ref(),
176 headers,
177 body,
178 timeout_secs,
179 )
180 .await
181 .map_err(HttpClientError::into_py_err)
182 })
183 }
184
185 #[pyo3(name = "get")]
187 #[pyo3(signature = (url, params=None, headers=None, keys=None, timeout_secs=None))]
188 fn py_get<'py>(
189 &self,
190 url: String,
191 params: Option<&Bound<'_, PyAny>>,
192 headers: Option<HashMap<String, String>>,
193 keys: Option<Vec<String>>,
194 timeout_secs: Option<u64>,
195 py: Python<'py>,
196 ) -> PyResult<Bound<'py, PyAny>> {
197 let client = self.clone();
198 let params = params_to_hashmap(params)?;
199 pyo3_async_runtimes::tokio::future_into_py(py, async move {
200 client
201 .get(url, params.as_ref(), headers, timeout_secs, keys)
202 .await
203 .map_err(HttpClientError::into_py_err)
204 })
205 }
206
207 #[expect(clippy::too_many_arguments)]
209 #[pyo3(name = "post")]
210 #[pyo3(signature = (url, params=None, headers=None, body=None, keys=None, timeout_secs=None))]
211 fn py_post<'py>(
212 &self,
213 url: String,
214 params: Option<&Bound<'_, PyAny>>,
215 headers: Option<HashMap<String, String>>,
216 body: Option<Vec<u8>>,
217 keys: Option<Vec<String>>,
218 timeout_secs: Option<u64>,
219 py: Python<'py>,
220 ) -> PyResult<Bound<'py, PyAny>> {
221 let client = self.clone();
222 let params = params_to_hashmap(params)?;
223 pyo3_async_runtimes::tokio::future_into_py(py, async move {
224 client
225 .post(url, params.as_ref(), headers, body, timeout_secs, keys)
226 .await
227 .map_err(HttpClientError::into_py_err)
228 })
229 }
230
231 #[expect(clippy::too_many_arguments)]
233 #[pyo3(name = "patch")]
234 #[pyo3(signature = (url, params=None, headers=None, body=None, keys=None, timeout_secs=None))]
235 fn py_patch<'py>(
236 &self,
237 url: String,
238 params: Option<&Bound<'_, PyAny>>,
239 headers: Option<HashMap<String, String>>,
240 body: Option<Vec<u8>>,
241 keys: Option<Vec<String>>,
242 timeout_secs: Option<u64>,
243 py: Python<'py>,
244 ) -> PyResult<Bound<'py, PyAny>> {
245 let client = self.clone();
246 let params = params_to_hashmap(params)?;
247 pyo3_async_runtimes::tokio::future_into_py(py, async move {
248 client
249 .patch(url, params.as_ref(), headers, body, timeout_secs, keys)
250 .await
251 .map_err(HttpClientError::into_py_err)
252 })
253 }
254
255 #[pyo3(name = "delete")]
257 #[pyo3(signature = (url, params=None, headers=None, keys=None, timeout_secs=None))]
258 fn py_delete<'py>(
259 &self,
260 url: String,
261 params: Option<&Bound<'_, PyAny>>,
262 headers: Option<HashMap<String, String>>,
263 keys: Option<Vec<String>>,
264 timeout_secs: Option<u64>,
265 py: Python<'py>,
266 ) -> PyResult<Bound<'py, PyAny>> {
267 let client = self.clone();
268 let params = params_to_hashmap(params)?;
269 pyo3_async_runtimes::tokio::future_into_py(py, async move {
270 client
271 .delete(url, params.as_ref(), headers, timeout_secs, keys)
272 .await
273 .map_err(HttpClientError::into_py_err)
274 })
275 }
276}
277
278fn params_to_hashmap(
284 params: Option<&Bound<'_, PyAny>>,
285) -> PyResult<Option<HashMap<String, Vec<String>>>> {
286 let Some(params) = params else {
287 return Ok(None);
288 };
289
290 let Ok(dict) = params.cast::<PyDict>() else {
291 return Err(to_pytype_err("params must be a dict"));
292 };
293
294 let mut result = HashMap::new();
295
296 for (key, value) in dict {
297 let key_str = key.str()?.to_str()?.to_string();
298
299 if let Ok(seq) = value.cast::<pyo3::types::PySequence>() {
300 if !value.is_instance_of::<pyo3::types::PyString>() {
302 let values: Vec<String> = (0..seq.len()?)
303 .map(|i| {
304 let item = seq.get_item(i)?;
305 Ok(item.str()?.to_str()?.to_string())
306 })
307 .collect::<PyResult<_>>()?;
308 result.insert(key_str, values);
309 continue;
310 }
311 }
312
313 let value_str = value.str()?.to_str()?.to_string();
314 result.insert(key_str, vec![value_str]);
315 }
316
317 Ok(Some(result))
318}
319
320#[pyfunction]
333#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.network")]
334#[pyo3(signature = (url, params=None, headers=None, timeout_secs=None))]
335pub fn http_get(
336 py: Python<'_>,
337 url: String,
338 params: Option<&Bound<'_, PyAny>>,
339 headers: Option<HashMap<String, String>>,
340 timeout_secs: Option<u64>,
341) -> PyResult<HttpResponse> {
342 let params_map = params_to_hashmap(params)?;
343
344 py.detach(|| {
346 join_blocking_http_thread(std::thread::spawn(move || {
347 let runtime = blocking_http_runtime()?;
348
349 runtime.block_on(async {
350 let client =
351 HttpClient::new(HashMap::new(), vec![], vec![], None, timeout_secs, None)
352 .map_err(HttpClientError::into_py_err)?;
353
354 client
355 .get(url, params_map.as_ref(), headers, timeout_secs, None)
356 .await
357 .map_err(HttpClientError::into_py_err)
358 })
359 }))
360 })
361}
362
363#[pyfunction]
376#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.network")]
377#[pyo3(signature = (url, params=None, headers=None, body=None, timeout_secs=None))]
378pub fn http_post(
379 py: Python<'_>,
380 url: String,
381 params: Option<&Bound<'_, PyAny>>,
382 headers: Option<HashMap<String, String>>,
383 body: Option<Vec<u8>>,
384 timeout_secs: Option<u64>,
385) -> PyResult<HttpResponse> {
386 let params_map = params_to_hashmap(params)?;
387
388 py.detach(|| {
390 join_blocking_http_thread(std::thread::spawn(move || {
391 let runtime = blocking_http_runtime()?;
392
393 runtime.block_on(async {
394 let client =
395 HttpClient::new(HashMap::new(), vec![], vec![], None, timeout_secs, None)
396 .map_err(HttpClientError::into_py_err)?;
397
398 client
399 .post(url, params_map.as_ref(), headers, body, timeout_secs, None)
400 .await
401 .map_err(HttpClientError::into_py_err)
402 })
403 }))
404 })
405}
406
407#[pyfunction]
420#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.network")]
421#[pyo3(signature = (url, params=None, headers=None, body=None, timeout_secs=None))]
422pub fn http_patch(
423 py: Python<'_>,
424 url: String,
425 params: Option<&Bound<'_, PyAny>>,
426 headers: Option<HashMap<String, String>>,
427 body: Option<Vec<u8>>,
428 timeout_secs: Option<u64>,
429) -> PyResult<HttpResponse> {
430 let params_map = params_to_hashmap(params)?;
431
432 py.detach(|| {
434 join_blocking_http_thread(std::thread::spawn(move || {
435 let runtime = blocking_http_runtime()?;
436
437 runtime.block_on(async {
438 let client =
439 HttpClient::new(HashMap::new(), vec![], vec![], None, timeout_secs, None)
440 .map_err(HttpClientError::into_py_err)?;
441
442 client
443 .patch(url, params_map.as_ref(), headers, body, timeout_secs, None)
444 .await
445 .map_err(HttpClientError::into_py_err)
446 })
447 }))
448 })
449}
450
451#[pyfunction]
464#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.network")]
465#[pyo3(signature = (url, params=None, headers=None, timeout_secs=None))]
466pub fn http_delete(
467 py: Python<'_>,
468 url: String,
469 params: Option<&Bound<'_, PyAny>>,
470 headers: Option<HashMap<String, String>>,
471 timeout_secs: Option<u64>,
472) -> PyResult<HttpResponse> {
473 let params_map = params_to_hashmap(params)?;
474
475 py.detach(|| {
477 join_blocking_http_thread(std::thread::spawn(move || {
478 let runtime = blocking_http_runtime()?;
479
480 runtime.block_on(async {
481 let client =
482 HttpClient::new(HashMap::new(), vec![], vec![], None, timeout_secs, None)
483 .map_err(HttpClientError::into_py_err)?;
484
485 client
486 .delete(url, params_map.as_ref(), headers, timeout_secs, None)
487 .await
488 .map_err(HttpClientError::into_py_err)
489 })
490 }))
491 })
492}
493
494fn blocking_http_runtime() -> PyResult<tokio::runtime::Runtime> {
495 tokio::runtime::Builder::new_current_thread()
496 .enable_all()
497 .build()
498 .map_err(to_pyruntime_err)
499}
500
501fn join_blocking_http_thread(
502 handle: std::thread::JoinHandle<PyResult<HttpResponse>>,
503) -> PyResult<HttpResponse> {
504 handle
505 .join()
506 .map_err(|_| to_pyruntime_err("HTTP request thread panicked"))?
507}
508
509#[pyfunction]
524#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.network")]
525#[pyo3(signature = (url, filepath, params=None, headers=None, timeout_secs=None))]
526pub fn http_download(
527 py: Python<'_>,
528 url: String,
529 filepath: &str,
530 params: Option<&Bound<'_, PyAny>>,
531 headers: Option<HashMap<String, String>>,
532 timeout_secs: Option<u64>,
533) -> PyResult<()> {
534 let params_map = params_to_hashmap(params)?;
535
536 let full_url = if let Some(ref params) = params_map {
538 let pairs: Vec<(String, String)> = params
540 .iter()
541 .flat_map(|(key, values)| values.iter().map(move |value| (key.clone(), value.clone())))
542 .collect();
543
544 if pairs.is_empty() {
545 url
546 } else {
547 let query_string = serde_urlencoded::to_string(pairs).map_err(to_pyvalue_err)?;
548 let separator = if url.contains('?') { '&' } else { '?' };
550 format!("{url}{separator}{query_string}")
551 }
552 } else {
553 url
554 };
555
556 py.detach(|| {
559 let filepath = Path::new(filepath);
560
561 if let Some(parent) = filepath.parent() {
562 std::fs::create_dir_all(parent).map_err(to_pyvalue_err)?;
563 }
564
565 let mut client_builder = Client::builder();
566
567 if let Some(timeout) = timeout_secs {
568 client_builder = client_builder.timeout(Duration::from_secs(timeout));
569 }
570 let client = client_builder.build().map_err(to_pyvalue_err)?;
571
572 let mut request_builder = client.get(&full_url);
573
574 if let Some(headers_map) = headers {
575 for (key, value) in headers_map {
576 request_builder = request_builder.header(key, value);
577 }
578 }
579
580 let mut response = request_builder.send().map_err(to_pyvalue_err)?;
581
582 if !response.status().is_success() {
583 return Err(to_pyruntime_err(format!(
584 "HTTP error: {}",
585 response.status()
586 )));
587 }
588
589 let mut file = File::create(filepath).map_err(to_pyvalue_err)?;
590 copy(&mut response, &mut file).map_err(to_pyvalue_err)?;
591
592 Ok(())
593 })
594}
595
596#[cfg(test)]
597mod tests {
598 use std::net::SocketAddr;
599
600 use axum::{Router, routing::get};
601 use pyo3::types::{PyDict, PyList, PyTuple};
602 use pyo3_async_runtimes::tokio::get_runtime;
603 use rstest::rstest;
604 use tokio::net::TcpListener;
605
606 use super::*;
607
608 #[rstest]
609 fn test_params_to_hashmap_none() {
610 pyo3::Python::initialize();
611
612 let result = Python::attach(|_py| params_to_hashmap(None)).unwrap();
613
614 assert!(result.is_none());
615 }
616
617 #[rstest]
618 fn test_params_to_hashmap_empty_dict() {
619 pyo3::Python::initialize();
620
621 let result = Python::attach(|py| {
622 let dict = PyDict::new(py);
623 params_to_hashmap(Some(dict.as_any()))
624 })
625 .unwrap();
626
627 assert!(result.is_some());
628 assert!(result.unwrap().is_empty());
629 }
630
631 #[rstest]
632 fn test_params_to_hashmap_single_string_value() {
633 pyo3::Python::initialize();
634
635 let result = Python::attach(|py| {
636 let dict = PyDict::new(py);
637 dict.set_item("key", "value").unwrap();
638 params_to_hashmap(Some(dict.as_any()))
639 })
640 .unwrap()
641 .unwrap();
642
643 assert_eq!(result.len(), 1);
644 assert_eq!(result.get("key").unwrap(), &vec!["value"]);
645 }
646
647 #[rstest]
648 fn test_params_to_hashmap_multiple_string_values() {
649 pyo3::Python::initialize();
650
651 let result = Python::attach(|py| {
652 let dict = PyDict::new(py);
653 dict.set_item("foo", "bar").unwrap();
654 dict.set_item("limit", "100").unwrap();
655 dict.set_item("offset", "0").unwrap();
656 params_to_hashmap(Some(dict.as_any()))
657 })
658 .unwrap()
659 .unwrap();
660
661 assert_eq!(result.len(), 3);
662 assert_eq!(result.get("foo").unwrap(), &vec!["bar"]);
663 assert_eq!(result.get("limit").unwrap(), &vec!["100"]);
664 assert_eq!(result.get("offset").unwrap(), &vec!["0"]);
665 }
666
667 #[rstest]
668 fn test_params_to_hashmap_int_value() {
669 pyo3::Python::initialize();
670
671 let result = Python::attach(|py| {
672 let dict = PyDict::new(py);
673 dict.set_item("limit", 100).unwrap();
674 params_to_hashmap(Some(dict.as_any()))
675 })
676 .unwrap()
677 .unwrap();
678
679 assert_eq!(result.len(), 1);
680 assert_eq!(result.get("limit").unwrap(), &vec!["100"]);
681 }
682
683 #[rstest]
684 fn test_params_to_hashmap_float_value() {
685 pyo3::Python::initialize();
686
687 let result = Python::attach(|py| {
688 let dict = PyDict::new(py);
689 dict.set_item("price", 123.45).unwrap();
690 params_to_hashmap(Some(dict.as_any()))
691 })
692 .unwrap()
693 .unwrap();
694
695 assert_eq!(result.len(), 1);
696 assert_eq!(result.get("price").unwrap(), &vec!["123.45"]);
697 }
698
699 #[rstest]
700 fn test_params_to_hashmap_bool_value() {
701 pyo3::Python::initialize();
702
703 let result = Python::attach(|py| {
704 let dict = PyDict::new(py);
705 dict.set_item("active", true).unwrap();
706 dict.set_item("closed", false).unwrap();
707 params_to_hashmap(Some(dict.as_any()))
708 })
709 .unwrap()
710 .unwrap();
711
712 assert_eq!(result.len(), 2);
713 assert_eq!(result.get("active").unwrap(), &vec!["True"]);
714 assert_eq!(result.get("closed").unwrap(), &vec!["False"]);
715 }
716
717 #[rstest]
718 fn test_params_to_hashmap_list_value() {
719 pyo3::Python::initialize();
720
721 let result = Python::attach(|py| {
722 let dict = PyDict::new(py);
723 let list = PyList::new(py, ["1", "2", "3"]).unwrap();
724 dict.set_item("id", list).unwrap();
725 params_to_hashmap(Some(dict.as_any()))
726 })
727 .unwrap()
728 .unwrap();
729
730 assert_eq!(result.len(), 1);
731 assert_eq!(result.get("id").unwrap(), &vec!["1", "2", "3"]);
732 }
733
734 #[rstest]
735 fn test_params_to_hashmap_tuple_value() {
736 pyo3::Python::initialize();
737
738 let result = Python::attach(|py| {
739 let dict = PyDict::new(py);
740 let tuple = PyTuple::new(py, ["a", "b", "c"]).unwrap();
741 dict.set_item("letters", tuple).unwrap();
742 params_to_hashmap(Some(dict.as_any()))
743 })
744 .unwrap()
745 .unwrap();
746
747 assert_eq!(result.len(), 1);
748 assert_eq!(result.get("letters").unwrap(), &vec!["a", "b", "c"]);
749 }
750
751 #[rstest]
752 fn test_params_to_hashmap_list_with_mixed_types() {
753 pyo3::Python::initialize();
754
755 let result = Python::attach(|py| {
756 let dict = PyDict::new(py);
757 let list = PyList::new(py, [1, 2, 3]).unwrap();
758 dict.set_item("nums", list).unwrap();
759 params_to_hashmap(Some(dict.as_any()))
760 })
761 .unwrap()
762 .unwrap();
763
764 assert_eq!(result.len(), 1);
765 assert_eq!(result.get("nums").unwrap(), &vec!["1", "2", "3"]);
766 }
767
768 #[rstest]
769 fn test_params_to_hashmap_mixed_values() {
770 pyo3::Python::initialize();
771
772 let result = Python::attach(|py| {
773 let dict = PyDict::new(py);
774 dict.set_item("name", "test").unwrap();
775 dict.set_item("limit", 50).unwrap();
776 let ids = PyList::new(py, ["1", "2"]).unwrap();
777 dict.set_item("id", ids).unwrap();
778 params_to_hashmap(Some(dict.as_any()))
779 })
780 .unwrap()
781 .unwrap();
782
783 assert_eq!(result.len(), 3);
784 assert_eq!(result.get("name").unwrap(), &vec!["test"]);
785 assert_eq!(result.get("limit").unwrap(), &vec!["50"]);
786 assert_eq!(result.get("id").unwrap(), &vec!["1", "2"]);
787 }
788
789 #[rstest]
790 fn test_params_to_hashmap_string_not_treated_as_sequence() {
791 pyo3::Python::initialize();
792
793 let result = Python::attach(|py| {
794 let dict = PyDict::new(py);
795 dict.set_item("text", "hello").unwrap();
796 params_to_hashmap(Some(dict.as_any()))
797 })
798 .unwrap()
799 .unwrap();
800
801 assert_eq!(result.len(), 1);
802 assert_eq!(result.get("text").unwrap(), &vec!["hello"]);
804 }
805
806 #[rstest]
807 fn test_params_to_hashmap_invalid_non_dict() {
808 pyo3::Python::initialize();
809
810 let result = Python::attach(|py| {
811 let list = PyList::new(py, ["a", "b"]).unwrap();
812 params_to_hashmap(Some(list.as_any()))
813 });
814
815 assert!(result.is_err());
816 let err = result.unwrap_err();
817 assert!(err.to_string().contains("params must be a dict"));
818 }
819
820 #[rstest]
821 fn test_params_to_hashmap_invalid_string_param() {
822 pyo3::Python::initialize();
823
824 let result = Python::attach(|py| {
825 let string = pyo3::types::PyString::new(py, "not a dict");
826 params_to_hashmap(Some(string.as_any()))
827 });
828
829 assert!(result.is_err());
830 let err = result.unwrap_err();
831 assert!(err.to_string().contains("params must be a dict"));
832 }
833
834 #[rstest]
835 fn test_join_blocking_http_thread_returns_runtime_error_on_panic() {
836 pyo3::Python::initialize();
837
838 let result = join_blocking_http_thread(std::thread::spawn(|| -> PyResult<HttpResponse> {
839 panic!("synthetic blocking HTTP panic")
840 }));
841
842 assert!(result.is_err());
843 let err = result.unwrap_err();
844 pyo3::Python::attach(|py| {
845 assert!(err.is_instance_of::<pyo3::exceptions::PyRuntimeError>(py));
846 });
847 assert_eq!(
848 err.to_string(),
849 "RuntimeError: HTTP request thread panicked"
850 );
851 }
852
853 fn create_test_router() -> Router {
854 Router::new()
855 .route("/get", get(|| async { "hello-world!" }))
856 .route("/post", axum::routing::post(|| async { "posted" }))
857 .route("/patch", axum::routing::patch(|| async { "patched" }))
858 .route("/delete", axum::routing::delete(|| async { "deleted" }))
859 }
860
861 async fn start_test_server() -> Result<SocketAddr, Box<dyn std::error::Error + Send + Sync>> {
862 let listener = TcpListener::bind("127.0.0.1:0").await?;
863 let addr = listener.local_addr()?;
864
865 tokio::spawn(async move {
866 let app = create_test_router();
867 axum::serve(listener, app).await.unwrap();
868 });
869
870 Ok(addr)
871 }
872
873 #[rstest]
874 fn test_blocking_http_get() {
875 pyo3::Python::initialize();
876
877 let addr = get_runtime().block_on(async { start_test_server().await.unwrap() });
878 let url = format!("http://{addr}/get");
879
880 let response = Python::attach(|py| http_get(py, url, None, None, Some(10))).unwrap();
881
882 assert!(response.status.is_success());
883 assert_eq!(String::from_utf8_lossy(&response.body), "hello-world!");
884 }
885
886 #[rstest]
887 fn test_blocking_http_post() {
888 pyo3::Python::initialize();
889
890 let addr = get_runtime().block_on(async { start_test_server().await.unwrap() });
891 let url = format!("http://{addr}/post");
892
893 let response = Python::attach(|py| http_post(py, url, None, None, None, Some(10))).unwrap();
894
895 assert!(response.status.is_success());
896 assert_eq!(String::from_utf8_lossy(&response.body), "posted");
897 }
898
899 #[rstest]
900 fn test_blocking_http_patch() {
901 pyo3::Python::initialize();
902
903 let addr = get_runtime().block_on(async { start_test_server().await.unwrap() });
904 let url = format!("http://{addr}/patch");
905
906 let response =
907 Python::attach(|py| http_patch(py, url, None, None, None, Some(10))).unwrap();
908
909 assert!(response.status.is_success());
910 assert_eq!(String::from_utf8_lossy(&response.body), "patched");
911 }
912
913 #[rstest]
914 fn test_blocking_http_delete() {
915 pyo3::Python::initialize();
916
917 let addr = get_runtime().block_on(async { start_test_server().await.unwrap() });
918 let url = format!("http://{addr}/delete");
919
920 let response = Python::attach(|py| http_delete(py, url, None, None, Some(10))).unwrap();
921
922 assert!(response.status.is_success());
923 assert_eq!(String::from_utf8_lossy(&response.body), "deleted");
924 }
925
926 #[rstest]
927 fn test_blocking_http_download() {
928 pyo3::Python::initialize();
929
930 let addr = get_runtime().block_on(async { start_test_server().await.unwrap() });
931 let url = format!("http://{addr}/get");
932 let temp_dir = std::env::temp_dir();
933 let filepath = temp_dir.join("test_download.txt");
934
935 Python::attach(|py| {
936 http_download(py, url, filepath.to_str().unwrap(), None, None, Some(10)).unwrap();
937 });
938
939 assert!(filepath.exists());
940 let content = std::fs::read_to_string(&filepath).unwrap();
941 assert_eq!(content, "hello-world!");
942
943 std::fs::remove_file(&filepath).ok();
944 }
945}