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 join_blocking_http_thread(std::thread::spawn(move || {
345 let runtime = blocking_http_runtime()?;
346
347 runtime.block_on(async {
348 let client = HttpClient::new(HashMap::new(), vec![], vec![], None, timeout_secs, None)
349 .map_err(HttpClientError::into_py_err)?;
350
351 client
352 .get(url, params_map.as_ref(), headers, timeout_secs, None)
353 .await
354 .map_err(HttpClientError::into_py_err)
355 })
356 }))
357}
358
359#[pyfunction]
372#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.network")]
373#[pyo3(signature = (url, params=None, headers=None, body=None, timeout_secs=None))]
374pub fn http_post(
375 _py: Python<'_>,
376 url: String,
377 params: Option<&Bound<'_, PyAny>>,
378 headers: Option<HashMap<String, String>>,
379 body: Option<Vec<u8>>,
380 timeout_secs: Option<u64>,
381) -> PyResult<HttpResponse> {
382 let params_map = params_to_hashmap(params)?;
383
384 join_blocking_http_thread(std::thread::spawn(move || {
385 let runtime = blocking_http_runtime()?;
386
387 runtime.block_on(async {
388 let client = HttpClient::new(HashMap::new(), vec![], vec![], None, timeout_secs, None)
389 .map_err(HttpClientError::into_py_err)?;
390
391 client
392 .post(url, params_map.as_ref(), headers, body, timeout_secs, None)
393 .await
394 .map_err(HttpClientError::into_py_err)
395 })
396 }))
397}
398
399#[pyfunction]
412#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.network")]
413#[pyo3(signature = (url, params=None, headers=None, body=None, timeout_secs=None))]
414pub fn http_patch(
415 _py: Python<'_>,
416 url: String,
417 params: Option<&Bound<'_, PyAny>>,
418 headers: Option<HashMap<String, String>>,
419 body: Option<Vec<u8>>,
420 timeout_secs: Option<u64>,
421) -> PyResult<HttpResponse> {
422 let params_map = params_to_hashmap(params)?;
423
424 join_blocking_http_thread(std::thread::spawn(move || {
425 let runtime = blocking_http_runtime()?;
426
427 runtime.block_on(async {
428 let client = HttpClient::new(HashMap::new(), vec![], vec![], None, timeout_secs, None)
429 .map_err(HttpClientError::into_py_err)?;
430
431 client
432 .patch(url, params_map.as_ref(), headers, body, timeout_secs, None)
433 .await
434 .map_err(HttpClientError::into_py_err)
435 })
436 }))
437}
438
439#[pyfunction]
452#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.network")]
453#[pyo3(signature = (url, params=None, headers=None, timeout_secs=None))]
454pub fn http_delete(
455 _py: Python<'_>,
456 url: String,
457 params: Option<&Bound<'_, PyAny>>,
458 headers: Option<HashMap<String, String>>,
459 timeout_secs: Option<u64>,
460) -> PyResult<HttpResponse> {
461 let params_map = params_to_hashmap(params)?;
462
463 join_blocking_http_thread(std::thread::spawn(move || {
464 let runtime = blocking_http_runtime()?;
465
466 runtime.block_on(async {
467 let client = HttpClient::new(HashMap::new(), vec![], vec![], None, timeout_secs, None)
468 .map_err(HttpClientError::into_py_err)?;
469
470 client
471 .delete(url, params_map.as_ref(), headers, timeout_secs, None)
472 .await
473 .map_err(HttpClientError::into_py_err)
474 })
475 }))
476}
477
478fn blocking_http_runtime() -> PyResult<tokio::runtime::Runtime> {
479 tokio::runtime::Builder::new_current_thread()
480 .enable_all()
481 .build()
482 .map_err(to_pyruntime_err)
483}
484
485fn join_blocking_http_thread(
486 handle: std::thread::JoinHandle<PyResult<HttpResponse>>,
487) -> PyResult<HttpResponse> {
488 handle
489 .join()
490 .map_err(|_| to_pyruntime_err("HTTP request thread panicked"))?
491}
492
493#[pyfunction]
508#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.network")]
509#[pyo3(signature = (url, filepath, params=None, headers=None, timeout_secs=None))]
510pub fn http_download(
511 _py: Python<'_>,
512 url: String,
513 filepath: &str,
514 params: Option<&Bound<'_, PyAny>>,
515 headers: Option<HashMap<String, String>>,
516 timeout_secs: Option<u64>,
517) -> PyResult<()> {
518 let params_map = params_to_hashmap(params)?;
519
520 let full_url = if let Some(ref params) = params_map {
522 let pairs: Vec<(String, String)> = params
524 .iter()
525 .flat_map(|(key, values)| values.iter().map(move |value| (key.clone(), value.clone())))
526 .collect();
527
528 if pairs.is_empty() {
529 url
530 } else {
531 let query_string = serde_urlencoded::to_string(pairs).map_err(to_pyvalue_err)?;
532 let separator = if url.contains('?') { '&' } else { '?' };
534 format!("{url}{separator}{query_string}")
535 }
536 } else {
537 url
538 };
539
540 let filepath = Path::new(filepath);
541
542 if let Some(parent) = filepath.parent() {
543 std::fs::create_dir_all(parent).map_err(to_pyvalue_err)?;
544 }
545
546 let mut client_builder = Client::builder();
547
548 if let Some(timeout) = timeout_secs {
549 client_builder = client_builder.timeout(Duration::from_secs(timeout));
550 }
551 let client = client_builder.build().map_err(to_pyvalue_err)?;
552
553 let mut request_builder = client.get(&full_url);
554
555 if let Some(headers_map) = headers {
556 for (key, value) in headers_map {
557 request_builder = request_builder.header(key, value);
558 }
559 }
560
561 let mut response = request_builder.send().map_err(to_pyvalue_err)?;
562
563 if !response.status().is_success() {
564 return Err(to_pyruntime_err(format!(
565 "HTTP error: {}",
566 response.status()
567 )));
568 }
569
570 let mut file = File::create(filepath).map_err(to_pyvalue_err)?;
571 copy(&mut response, &mut file).map_err(to_pyvalue_err)?;
572
573 Ok(())
574}
575
576#[cfg(test)]
577mod tests {
578 use std::net::SocketAddr;
579
580 use axum::{Router, routing::get};
581 use pyo3::types::{PyDict, PyList, PyTuple};
582 use pyo3_async_runtimes::tokio::get_runtime;
583 use rstest::rstest;
584 use tokio::net::TcpListener;
585
586 use super::*;
587
588 #[rstest]
589 fn test_params_to_hashmap_none() {
590 pyo3::Python::initialize();
591
592 let result = Python::attach(|_py| params_to_hashmap(None)).unwrap();
593
594 assert!(result.is_none());
595 }
596
597 #[rstest]
598 fn test_params_to_hashmap_empty_dict() {
599 pyo3::Python::initialize();
600
601 let result = Python::attach(|py| {
602 let dict = PyDict::new(py);
603 params_to_hashmap(Some(dict.as_any()))
604 })
605 .unwrap();
606
607 assert!(result.is_some());
608 assert!(result.unwrap().is_empty());
609 }
610
611 #[rstest]
612 fn test_params_to_hashmap_single_string_value() {
613 pyo3::Python::initialize();
614
615 let result = Python::attach(|py| {
616 let dict = PyDict::new(py);
617 dict.set_item("key", "value").unwrap();
618 params_to_hashmap(Some(dict.as_any()))
619 })
620 .unwrap()
621 .unwrap();
622
623 assert_eq!(result.len(), 1);
624 assert_eq!(result.get("key").unwrap(), &vec!["value"]);
625 }
626
627 #[rstest]
628 fn test_params_to_hashmap_multiple_string_values() {
629 pyo3::Python::initialize();
630
631 let result = Python::attach(|py| {
632 let dict = PyDict::new(py);
633 dict.set_item("foo", "bar").unwrap();
634 dict.set_item("limit", "100").unwrap();
635 dict.set_item("offset", "0").unwrap();
636 params_to_hashmap(Some(dict.as_any()))
637 })
638 .unwrap()
639 .unwrap();
640
641 assert_eq!(result.len(), 3);
642 assert_eq!(result.get("foo").unwrap(), &vec!["bar"]);
643 assert_eq!(result.get("limit").unwrap(), &vec!["100"]);
644 assert_eq!(result.get("offset").unwrap(), &vec!["0"]);
645 }
646
647 #[rstest]
648 fn test_params_to_hashmap_int_value() {
649 pyo3::Python::initialize();
650
651 let result = Python::attach(|py| {
652 let dict = PyDict::new(py);
653 dict.set_item("limit", 100).unwrap();
654 params_to_hashmap(Some(dict.as_any()))
655 })
656 .unwrap()
657 .unwrap();
658
659 assert_eq!(result.len(), 1);
660 assert_eq!(result.get("limit").unwrap(), &vec!["100"]);
661 }
662
663 #[rstest]
664 fn test_params_to_hashmap_float_value() {
665 pyo3::Python::initialize();
666
667 let result = Python::attach(|py| {
668 let dict = PyDict::new(py);
669 dict.set_item("price", 123.45).unwrap();
670 params_to_hashmap(Some(dict.as_any()))
671 })
672 .unwrap()
673 .unwrap();
674
675 assert_eq!(result.len(), 1);
676 assert_eq!(result.get("price").unwrap(), &vec!["123.45"]);
677 }
678
679 #[rstest]
680 fn test_params_to_hashmap_bool_value() {
681 pyo3::Python::initialize();
682
683 let result = Python::attach(|py| {
684 let dict = PyDict::new(py);
685 dict.set_item("active", true).unwrap();
686 dict.set_item("closed", false).unwrap();
687 params_to_hashmap(Some(dict.as_any()))
688 })
689 .unwrap()
690 .unwrap();
691
692 assert_eq!(result.len(), 2);
693 assert_eq!(result.get("active").unwrap(), &vec!["True"]);
694 assert_eq!(result.get("closed").unwrap(), &vec!["False"]);
695 }
696
697 #[rstest]
698 fn test_params_to_hashmap_list_value() {
699 pyo3::Python::initialize();
700
701 let result = Python::attach(|py| {
702 let dict = PyDict::new(py);
703 let list = PyList::new(py, ["1", "2", "3"]).unwrap();
704 dict.set_item("id", list).unwrap();
705 params_to_hashmap(Some(dict.as_any()))
706 })
707 .unwrap()
708 .unwrap();
709
710 assert_eq!(result.len(), 1);
711 assert_eq!(result.get("id").unwrap(), &vec!["1", "2", "3"]);
712 }
713
714 #[rstest]
715 fn test_params_to_hashmap_tuple_value() {
716 pyo3::Python::initialize();
717
718 let result = Python::attach(|py| {
719 let dict = PyDict::new(py);
720 let tuple = PyTuple::new(py, ["a", "b", "c"]).unwrap();
721 dict.set_item("letters", tuple).unwrap();
722 params_to_hashmap(Some(dict.as_any()))
723 })
724 .unwrap()
725 .unwrap();
726
727 assert_eq!(result.len(), 1);
728 assert_eq!(result.get("letters").unwrap(), &vec!["a", "b", "c"]);
729 }
730
731 #[rstest]
732 fn test_params_to_hashmap_list_with_mixed_types() {
733 pyo3::Python::initialize();
734
735 let result = Python::attach(|py| {
736 let dict = PyDict::new(py);
737 let list = PyList::new(py, [1, 2, 3]).unwrap();
738 dict.set_item("nums", list).unwrap();
739 params_to_hashmap(Some(dict.as_any()))
740 })
741 .unwrap()
742 .unwrap();
743
744 assert_eq!(result.len(), 1);
745 assert_eq!(result.get("nums").unwrap(), &vec!["1", "2", "3"]);
746 }
747
748 #[rstest]
749 fn test_params_to_hashmap_mixed_values() {
750 pyo3::Python::initialize();
751
752 let result = Python::attach(|py| {
753 let dict = PyDict::new(py);
754 dict.set_item("name", "test").unwrap();
755 dict.set_item("limit", 50).unwrap();
756 let ids = PyList::new(py, ["1", "2"]).unwrap();
757 dict.set_item("id", ids).unwrap();
758 params_to_hashmap(Some(dict.as_any()))
759 })
760 .unwrap()
761 .unwrap();
762
763 assert_eq!(result.len(), 3);
764 assert_eq!(result.get("name").unwrap(), &vec!["test"]);
765 assert_eq!(result.get("limit").unwrap(), &vec!["50"]);
766 assert_eq!(result.get("id").unwrap(), &vec!["1", "2"]);
767 }
768
769 #[rstest]
770 fn test_params_to_hashmap_string_not_treated_as_sequence() {
771 pyo3::Python::initialize();
772
773 let result = Python::attach(|py| {
774 let dict = PyDict::new(py);
775 dict.set_item("text", "hello").unwrap();
776 params_to_hashmap(Some(dict.as_any()))
777 })
778 .unwrap()
779 .unwrap();
780
781 assert_eq!(result.len(), 1);
782 assert_eq!(result.get("text").unwrap(), &vec!["hello"]);
784 }
785
786 #[rstest]
787 fn test_params_to_hashmap_invalid_non_dict() {
788 pyo3::Python::initialize();
789
790 let result = Python::attach(|py| {
791 let list = PyList::new(py, ["a", "b"]).unwrap();
792 params_to_hashmap(Some(list.as_any()))
793 });
794
795 assert!(result.is_err());
796 let err = result.unwrap_err();
797 assert!(err.to_string().contains("params must be a dict"));
798 }
799
800 #[rstest]
801 fn test_params_to_hashmap_invalid_string_param() {
802 pyo3::Python::initialize();
803
804 let result = Python::attach(|py| {
805 let string = pyo3::types::PyString::new(py, "not a dict");
806 params_to_hashmap(Some(string.as_any()))
807 });
808
809 assert!(result.is_err());
810 let err = result.unwrap_err();
811 assert!(err.to_string().contains("params must be a dict"));
812 }
813
814 #[rstest]
815 fn test_join_blocking_http_thread_returns_runtime_error_on_panic() {
816 pyo3::Python::initialize();
817
818 let result = join_blocking_http_thread(std::thread::spawn(|| -> PyResult<HttpResponse> {
819 panic!("synthetic blocking HTTP panic")
820 }));
821
822 assert!(result.is_err());
823 let err = result.unwrap_err();
824 pyo3::Python::attach(|py| {
825 assert!(err.is_instance_of::<pyo3::exceptions::PyRuntimeError>(py));
826 });
827 assert_eq!(
828 err.to_string(),
829 "RuntimeError: HTTP request thread panicked"
830 );
831 }
832
833 fn create_test_router() -> Router {
834 Router::new()
835 .route("/get", get(|| async { "hello-world!" }))
836 .route("/post", axum::routing::post(|| async { "posted" }))
837 .route("/patch", axum::routing::patch(|| async { "patched" }))
838 .route("/delete", axum::routing::delete(|| async { "deleted" }))
839 }
840
841 async fn start_test_server() -> Result<SocketAddr, Box<dyn std::error::Error + Send + Sync>> {
842 let listener = TcpListener::bind("127.0.0.1:0").await?;
843 let addr = listener.local_addr()?;
844
845 tokio::spawn(async move {
846 let app = create_test_router();
847 axum::serve(listener, app).await.unwrap();
848 });
849
850 Ok(addr)
851 }
852
853 #[rstest]
854 fn test_blocking_http_get() {
855 pyo3::Python::initialize();
856
857 let addr = get_runtime().block_on(async { start_test_server().await.unwrap() });
858 let url = format!("http://{addr}/get");
859
860 let response = Python::attach(|py| http_get(py, url, None, None, Some(10))).unwrap();
861
862 assert!(response.status.is_success());
863 assert_eq!(String::from_utf8_lossy(&response.body), "hello-world!");
864 }
865
866 #[rstest]
867 fn test_blocking_http_post() {
868 pyo3::Python::initialize();
869
870 let addr = get_runtime().block_on(async { start_test_server().await.unwrap() });
871 let url = format!("http://{addr}/post");
872
873 let response = Python::attach(|py| http_post(py, url, None, None, None, Some(10))).unwrap();
874
875 assert!(response.status.is_success());
876 assert_eq!(String::from_utf8_lossy(&response.body), "posted");
877 }
878
879 #[rstest]
880 fn test_blocking_http_patch() {
881 pyo3::Python::initialize();
882
883 let addr = get_runtime().block_on(async { start_test_server().await.unwrap() });
884 let url = format!("http://{addr}/patch");
885
886 let response =
887 Python::attach(|py| http_patch(py, url, None, None, None, Some(10))).unwrap();
888
889 assert!(response.status.is_success());
890 assert_eq!(String::from_utf8_lossy(&response.body), "patched");
891 }
892
893 #[rstest]
894 fn test_blocking_http_delete() {
895 pyo3::Python::initialize();
896
897 let addr = get_runtime().block_on(async { start_test_server().await.unwrap() });
898 let url = format!("http://{addr}/delete");
899
900 let response = Python::attach(|py| http_delete(py, url, None, None, Some(10))).unwrap();
901
902 assert!(response.status.is_success());
903 assert_eq!(String::from_utf8_lossy(&response.body), "deleted");
904 }
905
906 #[rstest]
907 fn test_blocking_http_download() {
908 pyo3::Python::initialize();
909
910 let addr = get_runtime().block_on(async { start_test_server().await.unwrap() });
911 let url = format!("http://{addr}/get");
912 let temp_dir = std::env::temp_dir();
913 let filepath = temp_dir.join("test_download.txt");
914
915 Python::attach(|py| {
916 http_download(py, url, filepath.to_str().unwrap(), None, None, Some(10)).unwrap();
917 });
918
919 assert!(filepath.exists());
920 let content = std::fs::read_to_string(&filepath).unwrap();
921 assert_eq!(content, "hello-world!");
922
923 std::fs::remove_file(&filepath).ok();
924 }
925}