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