Skip to main content

nautilus_network/python/
http.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 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::{
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
38// Python exception class for generic HTTP errors.
39create_exception!(network, HttpError, PyException);
40
41// Python exception class for generic HTTP timeout errors.
42create_exception!(network, HttpTimeoutError, PyException);
43
44// Python exception class for invalid proxy configuration.
45create_exception!(network, HttpInvalidProxyError, PyException);
46
47// Python exception class for HTTP client build errors.
48create_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]
63impl HttpMethod {
64    fn __hash__(&self) -> isize {
65        let mut h = DefaultHasher::new();
66        self.hash(&mut h);
67        h.finish() as isize
68    }
69}
70
71#[pymethods]
72impl HttpResponse {
73    /// Creates a new [`HttpResponse`] instance.
74    ///
75    /// # Errors
76    ///
77    /// Returns an error for an invalid `status` code.
78    #[new]
79    pub fn py_new(status: u16, body: Vec<u8>) -> PyResult<Self> {
80        Ok(Self {
81            status: HttpStatus::try_from(status).map_err(to_pyvalue_err)?,
82            headers: HashMap::new(),
83            body: Bytes::from(body),
84        })
85    }
86
87    #[getter]
88    #[pyo3(name = "status")]
89    pub const fn py_status(&self) -> u16 {
90        self.status.as_u16()
91    }
92
93    #[getter]
94    #[pyo3(name = "headers")]
95    pub fn py_headers(&self) -> HashMap<String, String> {
96        self.headers.clone()
97    }
98
99    #[getter]
100    #[pyo3(name = "body")]
101    pub fn py_body(&self) -> &[u8] {
102        self.body.as_ref()
103    }
104}
105
106#[pymethods]
107impl HttpClient {
108    /// Creates a new `HttpClient`.
109    ///
110    /// Rate limiting can be configured on a per-endpoint basis by passing
111    /// key-value pairs of endpoint URLs and their respective quotas.
112    ///
113    /// For /foo -> 10 reqs/sec configure limit with ("foo", `Quota.rate_per_second(10)`)
114    ///
115    /// Hierarchical rate limiting can be achieved by configuring the quotas for
116    /// each level.
117    ///
118    /// For /foo/bar -> 10 reqs/sec and /foo -> 20 reqs/sec configure limits for
119    /// keys "foo/bar" and "foo" respectively.
120    ///
121    /// When a request is made the URL should be split into all the keys within it.
122    ///
123    /// For request /foo/bar, should pass keys ["foo/bar", "foo"] for rate limiting.
124    ///
125    /// # Errors
126    ///
127    /// - Returns `HttpInvalidProxyError` if the proxy URL is malformed.
128    /// - Returns `HttpClientBuildError` if building the HTTP client fails.
129    #[new]
130    #[pyo3(signature = (default_headers=HashMap::new(), header_keys=Vec::new(), keyed_quotas=Vec::new(), default_quota=None, timeout_secs=None, proxy_url=None))]
131    pub fn py_new(
132        default_headers: HashMap<String, String>,
133        header_keys: Vec<String>,
134        keyed_quotas: Vec<(String, Quota)>,
135        default_quota: Option<Quota>,
136        timeout_secs: Option<u64>,
137        proxy_url: Option<String>,
138    ) -> PyResult<Self> {
139        Self::new(
140            default_headers,
141            header_keys,
142            keyed_quotas,
143            default_quota,
144            timeout_secs,
145            proxy_url,
146        )
147        .map_err(HttpClientError::into_py_err)
148    }
149
150    #[allow(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")]
186    #[pyo3(signature = (url, params=None, headers=None, keys=None, timeout_secs=None))]
187    fn py_get<'py>(
188        &self,
189        url: String,
190        params: Option<&Bound<'_, PyAny>>,
191        headers: Option<HashMap<String, String>>,
192        keys: Option<Vec<String>>,
193        timeout_secs: Option<u64>,
194        py: Python<'py>,
195    ) -> PyResult<Bound<'py, PyAny>> {
196        let client = self.clone();
197        let params = params_to_hashmap(params)?;
198        pyo3_async_runtimes::tokio::future_into_py(py, async move {
199            client
200                .get(url, params.as_ref(), headers, timeout_secs, keys)
201                .await
202                .map_err(HttpClientError::into_py_err)
203        })
204    }
205
206    #[allow(clippy::too_many_arguments)]
207    #[pyo3(name = "post")]
208    #[pyo3(signature = (url, params=None, headers=None, body=None, keys=None, timeout_secs=None))]
209    fn py_post<'py>(
210        &self,
211        url: String,
212        params: Option<&Bound<'_, PyAny>>,
213        headers: Option<HashMap<String, String>>,
214        body: Option<Vec<u8>>,
215        keys: Option<Vec<String>>,
216        timeout_secs: Option<u64>,
217        py: Python<'py>,
218    ) -> PyResult<Bound<'py, PyAny>> {
219        let client = self.clone();
220        let params = params_to_hashmap(params)?;
221        pyo3_async_runtimes::tokio::future_into_py(py, async move {
222            client
223                .post(url, params.as_ref(), headers, body, timeout_secs, keys)
224                .await
225                .map_err(HttpClientError::into_py_err)
226        })
227    }
228
229    #[allow(clippy::too_many_arguments)]
230    #[pyo3(name = "patch")]
231    #[pyo3(signature = (url, params=None, headers=None, body=None, keys=None, timeout_secs=None))]
232    fn py_patch<'py>(
233        &self,
234        url: String,
235        params: Option<&Bound<'_, PyAny>>,
236        headers: Option<HashMap<String, String>>,
237        body: Option<Vec<u8>>,
238        keys: Option<Vec<String>>,
239        timeout_secs: Option<u64>,
240        py: Python<'py>,
241    ) -> PyResult<Bound<'py, PyAny>> {
242        let client = self.clone();
243        let params = params_to_hashmap(params)?;
244        pyo3_async_runtimes::tokio::future_into_py(py, async move {
245            client
246                .patch(url, params.as_ref(), headers, body, timeout_secs, keys)
247                .await
248                .map_err(HttpClientError::into_py_err)
249        })
250    }
251
252    #[pyo3(name = "delete")]
253    #[pyo3(signature = (url, params=None, headers=None, keys=None, timeout_secs=None))]
254    fn py_delete<'py>(
255        &self,
256        url: String,
257        params: Option<&Bound<'_, PyAny>>,
258        headers: Option<HashMap<String, String>>,
259        keys: Option<Vec<String>>,
260        timeout_secs: Option<u64>,
261        py: Python<'py>,
262    ) -> PyResult<Bound<'py, PyAny>> {
263        let client = self.clone();
264        let params = params_to_hashmap(params)?;
265        pyo3_async_runtimes::tokio::future_into_py(py, async move {
266            client
267                .delete(url, params.as_ref(), headers, timeout_secs, keys)
268                .await
269                .map_err(HttpClientError::into_py_err)
270        })
271    }
272}
273
274/// Converts Python dict params to HashMap<String, Vec<String>> for URL encoding.
275///
276/// Accepts a dict where values can be:
277/// - Single values (str, int, float, bool) -> converted to single-item vec.
278/// - Lists/tuples of values -> each item converted to string.
279fn params_to_hashmap(
280    params: Option<&Bound<'_, PyAny>>,
281) -> PyResult<Option<HashMap<String, Vec<String>>>> {
282    let Some(params) = params else {
283        return Ok(None);
284    };
285
286    let Ok(dict) = params.cast::<PyDict>() else {
287        return Err(to_pytype_err("params must be a dict"));
288    };
289
290    let mut result = HashMap::new();
291
292    for (key, value) in dict {
293        let key_str = key.str()?.to_str()?.to_string();
294
295        if let Ok(seq) = value.cast::<pyo3::types::PySequence>() {
296            // Exclude strings (which are technically sequences in Python)
297            if !value.is_instance_of::<pyo3::types::PyString>() {
298                let values: Vec<String> = (0..seq.len()?)
299                    .map(|i| {
300                        let item = seq.get_item(i)?;
301                        Ok(item.str()?.to_str()?.to_string())
302                    })
303                    .collect::<PyResult<_>>()?;
304                result.insert(key_str, values);
305                continue;
306            }
307        }
308
309        let value_str = value.str()?.to_str()?.to_string();
310        result.insert(key_str, vec![value_str]);
311    }
312
313    Ok(Some(result))
314}
315
316/// Blocking HTTP GET request.
317///
318/// Creates an HttpClient internally and blocks on the async operation using a dedicated runtime.
319///
320/// # Errors
321///
322/// Returns an error if:
323/// - The HTTP client fails to initialize.
324/// - The HTTP request fails (e.g., network error, timeout, invalid URL).
325/// - The server returns an error response.
326/// - The params argument is not a dict.
327///
328/// # Panics
329///
330/// Panics if the spawned thread panics or runtime creation fails.
331#[pyfunction]
332#[pyo3(signature = (url, params=None, headers=None, timeout_secs=None))]
333pub fn http_get(
334    _py: Python<'_>,
335    url: String,
336    params: Option<&Bound<'_, PyAny>>,
337    headers: Option<HashMap<String, String>>,
338    timeout_secs: Option<u64>,
339) -> PyResult<HttpResponse> {
340    let params_map = params_to_hashmap(params)?;
341
342    std::thread::spawn(move || {
343        let runtime = tokio::runtime::Builder::new_current_thread()
344            .enable_all()
345            .build()
346            .expect("Failed to create runtime");
347
348        runtime.block_on(async {
349            let client = HttpClient::new(HashMap::new(), vec![], vec![], None, timeout_secs, None)
350                .map_err(HttpClientError::into_py_err)?;
351
352            client
353                .get(url, params_map.as_ref(), headers, timeout_secs, None)
354                .await
355                .map_err(HttpClientError::into_py_err)
356        })
357    })
358    .join()
359    .expect("Thread panicked")
360}
361
362/// Blocking HTTP POST request.
363///
364/// Creates an HttpClient internally and blocks on the async operation using a dedicated runtime.
365///
366/// # Errors
367///
368/// Returns an error if:
369/// - The HTTP client fails to initialize.
370/// - The HTTP request fails (e.g., network error, timeout, invalid URL).
371/// - The server returns an error response.
372/// - The params argument is not a dict.
373///
374/// # Panics
375///
376/// Panics if the spawned thread panics or runtime creation fails.
377#[pyfunction]
378#[pyo3(signature = (url, params=None, headers=None, body=None, timeout_secs=None))]
379pub fn http_post(
380    _py: Python<'_>,
381    url: String,
382    params: Option<&Bound<'_, PyAny>>,
383    headers: Option<HashMap<String, String>>,
384    body: Option<Vec<u8>>,
385    timeout_secs: Option<u64>,
386) -> PyResult<HttpResponse> {
387    let params_map = params_to_hashmap(params)?;
388
389    std::thread::spawn(move || {
390        let runtime = tokio::runtime::Builder::new_current_thread()
391            .enable_all()
392            .build()
393            .expect("Failed to create runtime");
394
395        runtime.block_on(async {
396            let client = HttpClient::new(HashMap::new(), vec![], vec![], None, timeout_secs, None)
397                .map_err(HttpClientError::into_py_err)?;
398
399            client
400                .post(url, params_map.as_ref(), headers, body, timeout_secs, None)
401                .await
402                .map_err(HttpClientError::into_py_err)
403        })
404    })
405    .join()
406    .expect("Thread panicked")
407}
408
409/// Blocking HTTP PATCH request.
410///
411/// Creates an HttpClient internally and blocks on the async operation using a dedicated runtime.
412///
413/// # Errors
414///
415/// Returns an error if:
416/// - The HTTP client fails to initialize.
417/// - The HTTP request fails (e.g., network error, timeout, invalid URL).
418/// - The server returns an error response.
419/// - The params argument is not a dict.
420///
421/// # Panics
422///
423/// Panics if the spawned thread panics or runtime creation fails.
424#[pyfunction]
425#[pyo3(signature = (url, params=None, headers=None, body=None, timeout_secs=None))]
426pub fn http_patch(
427    _py: Python<'_>,
428    url: String,
429    params: Option<&Bound<'_, PyAny>>,
430    headers: Option<HashMap<String, String>>,
431    body: Option<Vec<u8>>,
432    timeout_secs: Option<u64>,
433) -> PyResult<HttpResponse> {
434    let params_map = params_to_hashmap(params)?;
435
436    std::thread::spawn(move || {
437        let runtime = tokio::runtime::Builder::new_current_thread()
438            .enable_all()
439            .build()
440            .expect("Failed to create runtime");
441
442        runtime.block_on(async {
443            let client = HttpClient::new(HashMap::new(), vec![], vec![], None, timeout_secs, None)
444                .map_err(HttpClientError::into_py_err)?;
445
446            client
447                .patch(url, params_map.as_ref(), headers, body, timeout_secs, None)
448                .await
449                .map_err(HttpClientError::into_py_err)
450        })
451    })
452    .join()
453    .expect("Thread panicked")
454}
455
456/// Blocking HTTP DELETE request.
457///
458/// Creates an HttpClient internally and blocks on the async operation using a dedicated runtime.
459///
460/// # Errors
461///
462/// Returns an error if:
463/// - The HTTP client fails to initialize.
464/// - The HTTP request fails (e.g., network error, timeout, invalid URL).
465/// - The server returns an error response.
466/// - The params argument is not a dict.
467///
468/// # Panics
469///
470/// Panics if the spawned thread panics or runtime creation fails.
471#[pyfunction]
472#[pyo3(signature = (url, params=None, headers=None, timeout_secs=None))]
473pub fn http_delete(
474    _py: Python<'_>,
475    url: String,
476    params: Option<&Bound<'_, PyAny>>,
477    headers: Option<HashMap<String, String>>,
478    timeout_secs: Option<u64>,
479) -> PyResult<HttpResponse> {
480    let params_map = params_to_hashmap(params)?;
481
482    std::thread::spawn(move || {
483        let runtime = tokio::runtime::Builder::new_current_thread()
484            .enable_all()
485            .build()
486            .expect("Failed to create runtime");
487
488        runtime.block_on(async {
489            let client = HttpClient::new(HashMap::new(), vec![], vec![], None, timeout_secs, None)
490                .map_err(HttpClientError::into_py_err)?;
491
492            client
493                .delete(url, params_map.as_ref(), headers, timeout_secs, None)
494                .await
495                .map_err(HttpClientError::into_py_err)
496        })
497    })
498    .join()
499    .expect("Thread panicked")
500}
501
502/// Downloads a file from URL to filepath using streaming.
503///
504/// Uses `reqwest::blocking::Client` to stream the response directly to disk,
505/// avoiding loading large files into memory.
506///
507/// # Errors
508///
509/// Returns an error if:
510/// - Parent directories cannot be created.
511/// - The HTTP client fails to build.
512/// - The HTTP request fails (e.g., network error, timeout, invalid URL).
513/// - The server returns a non-success status code.
514/// - The file cannot be created or written to.
515/// - The params argument is not a dict.
516#[pyfunction]
517#[pyo3(signature = (url, filepath, params=None, headers=None, timeout_secs=None))]
518pub fn http_download(
519    _py: Python<'_>,
520    url: String,
521    filepath: String,
522    params: Option<&Bound<'_, PyAny>>,
523    headers: Option<HashMap<String, String>>,
524    timeout_secs: Option<u64>,
525) -> PyResult<()> {
526    let params_map = params_to_hashmap(params)?;
527
528    // Encode params into URL manually for blocking client
529    let full_url = if let Some(ref params) = params_map {
530        // Flatten HashMap<String, Vec<String>> into Vec<(String, String)>
531        let pairs: Vec<(String, String)> = params
532            .iter()
533            .flat_map(|(key, values)| values.iter().map(move |value| (key.clone(), value.clone())))
534            .collect();
535
536        if pairs.is_empty() {
537            url
538        } else {
539            let query_string = serde_urlencoded::to_string(pairs).map_err(to_pyvalue_err)?;
540            // Check if URL already has a query string
541            let separator = if url.contains('?') { '&' } else { '?' };
542            format!("{url}{separator}{query_string}")
543        }
544    } else {
545        url
546    };
547
548    let filepath = Path::new(&filepath);
549
550    if let Some(parent) = filepath.parent() {
551        std::fs::create_dir_all(parent).map_err(to_pyvalue_err)?;
552    }
553
554    let mut client_builder = Client::builder();
555
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
563    if let Some(headers_map) = headers {
564        for (key, value) in headers_map {
565            request_builder = request_builder.header(key, value);
566        }
567    }
568
569    let mut response = request_builder.send().map_err(to_pyvalue_err)?;
570
571    if !response.status().is_success() {
572        return Err(to_pyruntime_err(format!(
573            "HTTP error: {}",
574            response.status()
575        )));
576    }
577
578    let mut file = File::create(filepath).map_err(to_pyvalue_err)?;
579    copy(&mut response, &mut file).map_err(to_pyvalue_err)?;
580
581    Ok(())
582}
583
584#[cfg(test)]
585mod tests {
586    use std::net::SocketAddr;
587
588    use axum::{Router, routing::get};
589    use pyo3::types::{PyDict, PyList, PyTuple};
590    use pyo3_async_runtimes::tokio::get_runtime;
591    use rstest::rstest;
592    use tokio::net::TcpListener;
593
594    use super::*;
595
596    #[rstest]
597    fn test_params_to_hashmap_none() {
598        pyo3::Python::initialize();
599
600        let result = Python::attach(|_py| params_to_hashmap(None)).unwrap();
601
602        assert!(result.is_none());
603    }
604
605    #[rstest]
606    fn test_params_to_hashmap_empty_dict() {
607        pyo3::Python::initialize();
608
609        let result = Python::attach(|py| {
610            let dict = PyDict::new(py);
611            params_to_hashmap(Some(dict.as_any()))
612        })
613        .unwrap();
614
615        assert!(result.is_some());
616        assert!(result.unwrap().is_empty());
617    }
618
619    #[rstest]
620    fn test_params_to_hashmap_single_string_value() {
621        pyo3::Python::initialize();
622
623        let result = Python::attach(|py| {
624            let dict = PyDict::new(py);
625            dict.set_item("key", "value").unwrap();
626            params_to_hashmap(Some(dict.as_any()))
627        })
628        .unwrap()
629        .unwrap();
630
631        assert_eq!(result.len(), 1);
632        assert_eq!(result.get("key").unwrap(), &vec!["value"]);
633    }
634
635    #[rstest]
636    fn test_params_to_hashmap_multiple_string_values() {
637        pyo3::Python::initialize();
638
639        let result = Python::attach(|py| {
640            let dict = PyDict::new(py);
641            dict.set_item("foo", "bar").unwrap();
642            dict.set_item("limit", "100").unwrap();
643            dict.set_item("offset", "0").unwrap();
644            params_to_hashmap(Some(dict.as_any()))
645        })
646        .unwrap()
647        .unwrap();
648
649        assert_eq!(result.len(), 3);
650        assert_eq!(result.get("foo").unwrap(), &vec!["bar"]);
651        assert_eq!(result.get("limit").unwrap(), &vec!["100"]);
652        assert_eq!(result.get("offset").unwrap(), &vec!["0"]);
653    }
654
655    #[rstest]
656    fn test_params_to_hashmap_int_value() {
657        pyo3::Python::initialize();
658
659        let result = Python::attach(|py| {
660            let dict = PyDict::new(py);
661            dict.set_item("limit", 100).unwrap();
662            params_to_hashmap(Some(dict.as_any()))
663        })
664        .unwrap()
665        .unwrap();
666
667        assert_eq!(result.len(), 1);
668        assert_eq!(result.get("limit").unwrap(), &vec!["100"]);
669    }
670
671    #[rstest]
672    fn test_params_to_hashmap_float_value() {
673        pyo3::Python::initialize();
674
675        let result = Python::attach(|py| {
676            let dict = PyDict::new(py);
677            dict.set_item("price", 123.45).unwrap();
678            params_to_hashmap(Some(dict.as_any()))
679        })
680        .unwrap()
681        .unwrap();
682
683        assert_eq!(result.len(), 1);
684        assert_eq!(result.get("price").unwrap(), &vec!["123.45"]);
685    }
686
687    #[rstest]
688    fn test_params_to_hashmap_bool_value() {
689        pyo3::Python::initialize();
690
691        let result = Python::attach(|py| {
692            let dict = PyDict::new(py);
693            dict.set_item("active", true).unwrap();
694            dict.set_item("closed", false).unwrap();
695            params_to_hashmap(Some(dict.as_any()))
696        })
697        .unwrap()
698        .unwrap();
699
700        assert_eq!(result.len(), 2);
701        assert_eq!(result.get("active").unwrap(), &vec!["True"]);
702        assert_eq!(result.get("closed").unwrap(), &vec!["False"]);
703    }
704
705    #[rstest]
706    fn test_params_to_hashmap_list_value() {
707        pyo3::Python::initialize();
708
709        let result = Python::attach(|py| {
710            let dict = PyDict::new(py);
711            let list = PyList::new(py, ["1", "2", "3"]).unwrap();
712            dict.set_item("id", list).unwrap();
713            params_to_hashmap(Some(dict.as_any()))
714        })
715        .unwrap()
716        .unwrap();
717
718        assert_eq!(result.len(), 1);
719        assert_eq!(result.get("id").unwrap(), &vec!["1", "2", "3"]);
720    }
721
722    #[rstest]
723    fn test_params_to_hashmap_tuple_value() {
724        pyo3::Python::initialize();
725
726        let result = Python::attach(|py| {
727            let dict = PyDict::new(py);
728            let tuple = PyTuple::new(py, ["a", "b", "c"]).unwrap();
729            dict.set_item("letters", tuple).unwrap();
730            params_to_hashmap(Some(dict.as_any()))
731        })
732        .unwrap()
733        .unwrap();
734
735        assert_eq!(result.len(), 1);
736        assert_eq!(result.get("letters").unwrap(), &vec!["a", "b", "c"]);
737    }
738
739    #[rstest]
740    fn test_params_to_hashmap_list_with_mixed_types() {
741        pyo3::Python::initialize();
742
743        let result = Python::attach(|py| {
744            let dict = PyDict::new(py);
745            let list = PyList::new(py, [1, 2, 3]).unwrap();
746            dict.set_item("nums", list).unwrap();
747            params_to_hashmap(Some(dict.as_any()))
748        })
749        .unwrap()
750        .unwrap();
751
752        assert_eq!(result.len(), 1);
753        assert_eq!(result.get("nums").unwrap(), &vec!["1", "2", "3"]);
754    }
755
756    #[rstest]
757    fn test_params_to_hashmap_mixed_values() {
758        pyo3::Python::initialize();
759
760        let result = Python::attach(|py| {
761            let dict = PyDict::new(py);
762            dict.set_item("name", "test").unwrap();
763            dict.set_item("limit", 50).unwrap();
764            let ids = PyList::new(py, ["1", "2"]).unwrap();
765            dict.set_item("id", ids).unwrap();
766            params_to_hashmap(Some(dict.as_any()))
767        })
768        .unwrap()
769        .unwrap();
770
771        assert_eq!(result.len(), 3);
772        assert_eq!(result.get("name").unwrap(), &vec!["test"]);
773        assert_eq!(result.get("limit").unwrap(), &vec!["50"]);
774        assert_eq!(result.get("id").unwrap(), &vec!["1", "2"]);
775    }
776
777    #[rstest]
778    fn test_params_to_hashmap_string_not_treated_as_sequence() {
779        pyo3::Python::initialize();
780
781        let result = Python::attach(|py| {
782            let dict = PyDict::new(py);
783            dict.set_item("text", "hello").unwrap();
784            params_to_hashmap(Some(dict.as_any()))
785        })
786        .unwrap()
787        .unwrap();
788
789        assert_eq!(result.len(), 1);
790        // String should be treated as single value, not as sequence of chars
791        assert_eq!(result.get("text").unwrap(), &vec!["hello"]);
792    }
793
794    #[rstest]
795    fn test_params_to_hashmap_invalid_non_dict() {
796        pyo3::Python::initialize();
797
798        let result = Python::attach(|py| {
799            let list = PyList::new(py, ["a", "b"]).unwrap();
800            params_to_hashmap(Some(list.as_any()))
801        });
802
803        assert!(result.is_err());
804        let err = result.unwrap_err();
805        assert!(err.to_string().contains("params must be a dict"));
806    }
807
808    #[rstest]
809    fn test_params_to_hashmap_invalid_string_param() {
810        pyo3::Python::initialize();
811
812        let result = Python::attach(|py| {
813            let string = pyo3::types::PyString::new(py, "not a dict");
814            params_to_hashmap(Some(string.as_any()))
815        });
816
817        assert!(result.is_err());
818        let err = result.unwrap_err();
819        assert!(err.to_string().contains("params must be a dict"));
820    }
821
822    async fn create_test_router() -> Router {
823        Router::new()
824            .route("/get", get(|| async { "hello-world!" }))
825            .route("/post", axum::routing::post(|| async { "posted" }))
826            .route("/patch", axum::routing::patch(|| async { "patched" }))
827            .route("/delete", axum::routing::delete(|| async { "deleted" }))
828    }
829
830    async fn start_test_server() -> Result<SocketAddr, Box<dyn std::error::Error + Send + Sync>> {
831        let listener = TcpListener::bind("127.0.0.1:0").await?;
832        let addr = listener.local_addr()?;
833
834        tokio::spawn(async move {
835            let app = create_test_router().await;
836            axum::serve(listener, app).await.unwrap();
837        });
838
839        Ok(addr)
840    }
841
842    #[rstest]
843    fn test_blocking_http_get() {
844        pyo3::Python::initialize();
845
846        let addr = get_runtime().block_on(async { start_test_server().await.unwrap() });
847        let url = format!("http://{addr}/get");
848
849        let response = Python::attach(|py| http_get(py, url, None, None, Some(10))).unwrap();
850
851        assert!(response.status.is_success());
852        assert_eq!(String::from_utf8_lossy(&response.body), "hello-world!");
853    }
854
855    #[rstest]
856    fn test_blocking_http_post() {
857        pyo3::Python::initialize();
858
859        let addr = get_runtime().block_on(async { start_test_server().await.unwrap() });
860        let url = format!("http://{addr}/post");
861
862        let response = Python::attach(|py| http_post(py, url, None, None, None, Some(10))).unwrap();
863
864        assert!(response.status.is_success());
865        assert_eq!(String::from_utf8_lossy(&response.body), "posted");
866    }
867
868    #[rstest]
869    fn test_blocking_http_patch() {
870        pyo3::Python::initialize();
871
872        let addr = get_runtime().block_on(async { start_test_server().await.unwrap() });
873        let url = format!("http://{addr}/patch");
874
875        let response =
876            Python::attach(|py| http_patch(py, url, None, None, None, Some(10))).unwrap();
877
878        assert!(response.status.is_success());
879        assert_eq!(String::from_utf8_lossy(&response.body), "patched");
880    }
881
882    #[rstest]
883    fn test_blocking_http_delete() {
884        pyo3::Python::initialize();
885
886        let addr = get_runtime().block_on(async { start_test_server().await.unwrap() });
887        let url = format!("http://{addr}/delete");
888
889        let response = Python::attach(|py| http_delete(py, url, None, None, Some(10))).unwrap();
890
891        assert!(response.status.is_success());
892        assert_eq!(String::from_utf8_lossy(&response.body), "deleted");
893    }
894
895    #[rstest]
896    fn test_blocking_http_download() {
897        pyo3::Python::initialize();
898
899        let addr = get_runtime().block_on(async { start_test_server().await.unwrap() });
900        let url = format!("http://{addr}/get");
901        let temp_dir = std::env::temp_dir();
902        let filepath = temp_dir.join("test_download.txt");
903
904        Python::attach(|py| {
905            http_download(
906                py,
907                url,
908                filepath.to_str().unwrap().to_string(),
909                None,
910                None,
911                Some(10),
912            )
913            .unwrap();
914        });
915
916        assert!(filepath.exists());
917        let content = std::fs::read_to_string(&filepath).unwrap();
918        assert_eq!(content, "hello-world!");
919
920        std::fs::remove_file(&filepath).ok();
921    }
922}