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