nautilus_network/python/
http.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use 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
37// Python exception class for generic HTTP errors.
38create_exception!(network, HttpError, PyException);
39
40// Python exception class for generic HTTP timeout errors.
41create_exception!(network, HttpTimeoutError, PyException);
42
43// Python exception class for invalid proxy configuration.
44create_exception!(network, HttpInvalidProxyError, PyException);
45
46// Python exception class for HTTP client build errors.
47create_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    /// Creates a new [`HttpResponse`] instance.
73    ///
74    /// # Errors
75    ///
76    /// Returns an error for an invalid `status` code.
77    #[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    /// Creates a new `HttpClient`.
108    ///
109    /// Rate limiting can be configured on a per-endpoint basis by passing
110    /// key-value pairs of endpoint URLs and their respective quotas.
111    ///
112    /// For /foo -> 10 reqs/sec configure limit with ("foo", `Quota.rate_per_second(10)`)
113    ///
114    /// Hierarchical rate limiting can be achieved by configuring the quotas for
115    /// each level.
116    ///
117    /// For /foo/bar -> 10 reqs/sec and /foo -> 20 reqs/sec configure limits for
118    /// keys "foo/bar" and "foo" respectively.
119    ///
120    /// When a request is made the URL should be split into all the keys within it.
121    ///
122    /// For request /foo/bar, should pass keys ["foo/bar", "foo"] for rate limiting.
123    ///
124    /// # Errors
125    ///
126    /// - Returns `HttpInvalidProxyError` if the proxy URL is malformed.
127    /// - Returns `HttpClientBuildError` if building the HTTP client fails.
128    #[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
273/// Converts Python dict params to HashMap<String, Vec<String>> for URL encoding.
274///
275/// Accepts a dict where values can be:
276/// - Single values (str, int, float, bool) -> converted to single-item vec.
277/// - Lists/tuples of values -> each item converted to string.
278fn 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            // Exclude strings (which are technically sequences in Python)
298            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/// Blocking HTTP GET request.
318///
319/// Creates an HttpClient internally and blocks on the async operation using a dedicated runtime.
320///
321/// # Errors
322///
323/// Returns an error if:
324/// - The HTTP client fails to initialize.
325/// - The HTTP request fails (e.g., network error, timeout, invalid URL).
326/// - The server returns an error response.
327/// - The params argument is not a dict.
328///
329/// # Panics
330///
331/// Panics if the spawned thread panics or runtime creation fails.
332#[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/// Blocking HTTP POST request.
364///
365/// Creates an HttpClient internally and blocks on the async operation using a dedicated runtime.
366///
367/// # Errors
368///
369/// Returns an error if:
370/// - The HTTP client fails to initialize.
371/// - The HTTP request fails (e.g., network error, timeout, invalid URL).
372/// - The server returns an error response.
373/// - The params argument is not a dict.
374///
375/// # Panics
376///
377/// Panics if the spawned thread panics or runtime creation fails.
378#[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/// Blocking HTTP PATCH request.
411///
412/// Creates an HttpClient internally and blocks on the async operation using a dedicated runtime.
413///
414/// # Errors
415///
416/// Returns an error if:
417/// - The HTTP client fails to initialize.
418/// - The HTTP request fails (e.g., network error, timeout, invalid URL).
419/// - The server returns an error response.
420/// - The params argument is not a dict.
421///
422/// # Panics
423///
424/// Panics if the spawned thread panics or runtime creation fails.
425#[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/// Blocking HTTP DELETE request.
458///
459/// Creates an HttpClient internally and blocks on the async operation using a dedicated runtime.
460///
461/// # Errors
462///
463/// Returns an error if:
464/// - The HTTP client fails to initialize.
465/// - The HTTP request fails (e.g., network error, timeout, invalid URL).
466/// - The server returns an error response.
467/// - The params argument is not a dict.
468///
469/// # Panics
470///
471/// Panics if the spawned thread panics or runtime creation fails.
472#[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/// Downloads a file from URL to filepath using streaming.
504///
505/// Uses `reqwest::blocking::Client` to stream the response directly to disk,
506/// avoiding loading large files into memory.
507///
508/// # Errors
509///
510/// Returns an error if:
511/// - Parent directories cannot be created.
512/// - The HTTP client fails to build.
513/// - The HTTP request fails (e.g., network error, timeout, invalid URL).
514/// - The server returns a non-success status code.
515/// - The file cannot be created or written to.
516/// - The params argument is not a dict.
517#[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    // Encode params into URL manually for blocking client
530    let full_url = if let Some(ref params) = params_map {
531        // Flatten HashMap<String, Vec<String>> into Vec<(String, String)>
532        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            // Check if URL already has a query string
542            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////////////////////////////////////////////////////////////////////////////////
584// Tests
585////////////////////////////////////////////////////////////////////////////////
586
587#[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        // String should be treated as single value, not as sequence of chars
793        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}