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