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