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    if let Some(timeout) = timeout_secs {
556        client_builder = client_builder.timeout(Duration::from_secs(timeout));
557    }
558    let client = client_builder.build().map_err(to_pyvalue_err)?;
559
560    let mut request_builder = client.get(&full_url);
561    if let Some(headers_map) = headers {
562        for (key, value) in headers_map {
563            request_builder = request_builder.header(key, value);
564        }
565    }
566
567    let mut response = request_builder.send().map_err(to_pyvalue_err)?;
568
569    if !response.status().is_success() {
570        return Err(to_pyruntime_err(format!(
571            "HTTP error: {}",
572            response.status()
573        )));
574    }
575
576    let mut file = File::create(filepath).map_err(to_pyvalue_err)?;
577    copy(&mut response, &mut file).map_err(to_pyvalue_err)?;
578
579    Ok(())
580}
581
582#[cfg(test)]
583mod tests {
584    use std::net::SocketAddr;
585
586    use axum::{Router, routing::get};
587    use pyo3::types::{PyDict, PyList, PyTuple};
588    use pyo3_async_runtimes::tokio::get_runtime;
589    use rstest::rstest;
590    use tokio::net::TcpListener;
591
592    use super::*;
593
594    #[rstest]
595    fn test_params_to_hashmap_none() {
596        pyo3::Python::initialize();
597
598        let result = Python::attach(|_py| params_to_hashmap(None)).unwrap();
599
600        assert!(result.is_none());
601    }
602
603    #[rstest]
604    fn test_params_to_hashmap_empty_dict() {
605        pyo3::Python::initialize();
606
607        let result = Python::attach(|py| {
608            let dict = PyDict::new(py);
609            params_to_hashmap(Some(dict.as_any()))
610        })
611        .unwrap();
612
613        assert!(result.is_some());
614        assert!(result.unwrap().is_empty());
615    }
616
617    #[rstest]
618    fn test_params_to_hashmap_single_string_value() {
619        pyo3::Python::initialize();
620
621        let result = Python::attach(|py| {
622            let dict = PyDict::new(py);
623            dict.set_item("key", "value").unwrap();
624            params_to_hashmap(Some(dict.as_any()))
625        })
626        .unwrap()
627        .unwrap();
628
629        assert_eq!(result.len(), 1);
630        assert_eq!(result.get("key").unwrap(), &vec!["value"]);
631    }
632
633    #[rstest]
634    fn test_params_to_hashmap_multiple_string_values() {
635        pyo3::Python::initialize();
636
637        let result = Python::attach(|py| {
638            let dict = PyDict::new(py);
639            dict.set_item("foo", "bar").unwrap();
640            dict.set_item("limit", "100").unwrap();
641            dict.set_item("offset", "0").unwrap();
642            params_to_hashmap(Some(dict.as_any()))
643        })
644        .unwrap()
645        .unwrap();
646
647        assert_eq!(result.len(), 3);
648        assert_eq!(result.get("foo").unwrap(), &vec!["bar"]);
649        assert_eq!(result.get("limit").unwrap(), &vec!["100"]);
650        assert_eq!(result.get("offset").unwrap(), &vec!["0"]);
651    }
652
653    #[rstest]
654    fn test_params_to_hashmap_int_value() {
655        pyo3::Python::initialize();
656
657        let result = Python::attach(|py| {
658            let dict = PyDict::new(py);
659            dict.set_item("limit", 100).unwrap();
660            params_to_hashmap(Some(dict.as_any()))
661        })
662        .unwrap()
663        .unwrap();
664
665        assert_eq!(result.len(), 1);
666        assert_eq!(result.get("limit").unwrap(), &vec!["100"]);
667    }
668
669    #[rstest]
670    fn test_params_to_hashmap_float_value() {
671        pyo3::Python::initialize();
672
673        let result = Python::attach(|py| {
674            let dict = PyDict::new(py);
675            dict.set_item("price", 123.45).unwrap();
676            params_to_hashmap(Some(dict.as_any()))
677        })
678        .unwrap()
679        .unwrap();
680
681        assert_eq!(result.len(), 1);
682        assert_eq!(result.get("price").unwrap(), &vec!["123.45"]);
683    }
684
685    #[rstest]
686    fn test_params_to_hashmap_bool_value() {
687        pyo3::Python::initialize();
688
689        let result = Python::attach(|py| {
690            let dict = PyDict::new(py);
691            dict.set_item("active", true).unwrap();
692            dict.set_item("closed", false).unwrap();
693            params_to_hashmap(Some(dict.as_any()))
694        })
695        .unwrap()
696        .unwrap();
697
698        assert_eq!(result.len(), 2);
699        assert_eq!(result.get("active").unwrap(), &vec!["True"]);
700        assert_eq!(result.get("closed").unwrap(), &vec!["False"]);
701    }
702
703    #[rstest]
704    fn test_params_to_hashmap_list_value() {
705        pyo3::Python::initialize();
706
707        let result = Python::attach(|py| {
708            let dict = PyDict::new(py);
709            let list = PyList::new(py, ["1", "2", "3"]).unwrap();
710            dict.set_item("id", list).unwrap();
711            params_to_hashmap(Some(dict.as_any()))
712        })
713        .unwrap()
714        .unwrap();
715
716        assert_eq!(result.len(), 1);
717        assert_eq!(result.get("id").unwrap(), &vec!["1", "2", "3"]);
718    }
719
720    #[rstest]
721    fn test_params_to_hashmap_tuple_value() {
722        pyo3::Python::initialize();
723
724        let result = Python::attach(|py| {
725            let dict = PyDict::new(py);
726            let tuple = PyTuple::new(py, ["a", "b", "c"]).unwrap();
727            dict.set_item("letters", tuple).unwrap();
728            params_to_hashmap(Some(dict.as_any()))
729        })
730        .unwrap()
731        .unwrap();
732
733        assert_eq!(result.len(), 1);
734        assert_eq!(result.get("letters").unwrap(), &vec!["a", "b", "c"]);
735    }
736
737    #[rstest]
738    fn test_params_to_hashmap_list_with_mixed_types() {
739        pyo3::Python::initialize();
740
741        let result = Python::attach(|py| {
742            let dict = PyDict::new(py);
743            let list = PyList::new(py, [1, 2, 3]).unwrap();
744            dict.set_item("nums", list).unwrap();
745            params_to_hashmap(Some(dict.as_any()))
746        })
747        .unwrap()
748        .unwrap();
749
750        assert_eq!(result.len(), 1);
751        assert_eq!(result.get("nums").unwrap(), &vec!["1", "2", "3"]);
752    }
753
754    #[rstest]
755    fn test_params_to_hashmap_mixed_values() {
756        pyo3::Python::initialize();
757
758        let result = Python::attach(|py| {
759            let dict = PyDict::new(py);
760            dict.set_item("name", "test").unwrap();
761            dict.set_item("limit", 50).unwrap();
762            let ids = PyList::new(py, ["1", "2"]).unwrap();
763            dict.set_item("id", ids).unwrap();
764            params_to_hashmap(Some(dict.as_any()))
765        })
766        .unwrap()
767        .unwrap();
768
769        assert_eq!(result.len(), 3);
770        assert_eq!(result.get("name").unwrap(), &vec!["test"]);
771        assert_eq!(result.get("limit").unwrap(), &vec!["50"]);
772        assert_eq!(result.get("id").unwrap(), &vec!["1", "2"]);
773    }
774
775    #[rstest]
776    fn test_params_to_hashmap_string_not_treated_as_sequence() {
777        pyo3::Python::initialize();
778
779        let result = Python::attach(|py| {
780            let dict = PyDict::new(py);
781            dict.set_item("text", "hello").unwrap();
782            params_to_hashmap(Some(dict.as_any()))
783        })
784        .unwrap()
785        .unwrap();
786
787        assert_eq!(result.len(), 1);
788        // String should be treated as single value, not as sequence of chars
789        assert_eq!(result.get("text").unwrap(), &vec!["hello"]);
790    }
791
792    #[rstest]
793    fn test_params_to_hashmap_invalid_non_dict() {
794        pyo3::Python::initialize();
795
796        let result = Python::attach(|py| {
797            let list = PyList::new(py, ["a", "b"]).unwrap();
798            params_to_hashmap(Some(list.as_any()))
799        });
800
801        assert!(result.is_err());
802        let err = result.unwrap_err();
803        assert!(err.to_string().contains("params must be a dict"));
804    }
805
806    #[rstest]
807    fn test_params_to_hashmap_invalid_string_param() {
808        pyo3::Python::initialize();
809
810        let result = Python::attach(|py| {
811            let string = pyo3::types::PyString::new(py, "not a dict");
812            params_to_hashmap(Some(string.as_any()))
813        });
814
815        assert!(result.is_err());
816        let err = result.unwrap_err();
817        assert!(err.to_string().contains("params must be a dict"));
818    }
819
820    async fn create_test_router() -> Router {
821        Router::new()
822            .route("/get", get(|| async { "hello-world!" }))
823            .route("/post", axum::routing::post(|| async { "posted" }))
824            .route("/patch", axum::routing::patch(|| async { "patched" }))
825            .route("/delete", axum::routing::delete(|| async { "deleted" }))
826    }
827
828    async fn start_test_server() -> Result<SocketAddr, Box<dyn std::error::Error + Send + Sync>> {
829        let listener = TcpListener::bind("127.0.0.1:0").await?;
830        let addr = listener.local_addr()?;
831
832        tokio::spawn(async move {
833            let app = create_test_router().await;
834            axum::serve(listener, app).await.unwrap();
835        });
836
837        Ok(addr)
838    }
839
840    #[rstest]
841    fn test_blocking_http_get() {
842        pyo3::Python::initialize();
843
844        let addr = get_runtime().block_on(async { start_test_server().await.unwrap() });
845        let url = format!("http://{addr}/get");
846
847        let response = Python::attach(|py| http_get(py, url, None, None, Some(10))).unwrap();
848
849        assert!(response.status.is_success());
850        assert_eq!(String::from_utf8_lossy(&response.body), "hello-world!");
851    }
852
853    #[rstest]
854    fn test_blocking_http_post() {
855        pyo3::Python::initialize();
856
857        let addr = get_runtime().block_on(async { start_test_server().await.unwrap() });
858        let url = format!("http://{addr}/post");
859
860        let response = Python::attach(|py| http_post(py, url, None, None, None, Some(10))).unwrap();
861
862        assert!(response.status.is_success());
863        assert_eq!(String::from_utf8_lossy(&response.body), "posted");
864    }
865
866    #[rstest]
867    fn test_blocking_http_patch() {
868        pyo3::Python::initialize();
869
870        let addr = get_runtime().block_on(async { start_test_server().await.unwrap() });
871        let url = format!("http://{addr}/patch");
872
873        let response =
874            Python::attach(|py| http_patch(py, url, None, None, None, Some(10))).unwrap();
875
876        assert!(response.status.is_success());
877        assert_eq!(String::from_utf8_lossy(&response.body), "patched");
878    }
879
880    #[rstest]
881    fn test_blocking_http_delete() {
882        pyo3::Python::initialize();
883
884        let addr = get_runtime().block_on(async { start_test_server().await.unwrap() });
885        let url = format!("http://{addr}/delete");
886
887        let response = Python::attach(|py| http_delete(py, url, None, None, Some(10))).unwrap();
888
889        assert!(response.status.is_success());
890        assert_eq!(String::from_utf8_lossy(&response.body), "deleted");
891    }
892
893    #[rstest]
894    fn test_blocking_http_download() {
895        pyo3::Python::initialize();
896
897        let addr = get_runtime().block_on(async { start_test_server().await.unwrap() });
898        let url = format!("http://{addr}/get");
899        let temp_dir = std::env::temp_dir();
900        let filepath = temp_dir.join("test_download.txt");
901
902        Python::attach(|py| {
903            http_download(
904                py,
905                url,
906                filepath.to_str().unwrap().to_string(),
907                None,
908                None,
909                Some(10),
910            )
911            .unwrap();
912        });
913
914        assert!(filepath.exists());
915        let content = std::fs::read_to_string(&filepath).unwrap();
916        assert_eq!(content, "hello-world!");
917
918        std::fs::remove_file(&filepath).ok();
919    }
920}