Skip to main content

qobuz_api/api/
http_client.rs

1//! HTTP client trait abstraction for deterministic testing.
2
3use std::{future::Future, pin::Pin};
4
5use reqwest::{
6    Client, Response,
7    header::{HeaderMap, HeaderName, HeaderValue},
8};
9
10use crate::errors::QobuzApiError;
11
12/// Type alias for a pinned, boxed, `Send` future.
13type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
14
15/// HTTP client trait enabling deterministic testing via mock implementations.
16///
17/// Uses boxed futures instead of `async fn` to remain object-safe for `dyn` dispatch.
18pub trait HttpClient: Send + Sync {
19    /// Sends a GET request with query parameters.
20    ///
21    /// # Arguments
22    ///
23    /// * `url` - Full URL to send the request to
24    /// * `params` - Key-value query parameter pairs
25    ///
26    /// # Returns
27    ///
28    /// The HTTP response.
29    fn get(
30        &self,
31        url: &str,
32        params: &[(&str, &str)],
33    ) -> BoxFuture<'_, Result<Response, QobuzApiError>>;
34
35    /// Sends a POST request with form parameters.
36    ///
37    /// # Arguments
38    ///
39    /// * `url` - Full URL to send the request to
40    /// * `params` - Key-value form parameter pairs
41    ///
42    /// # Returns
43    ///
44    /// The HTTP response.
45    fn post_form(
46        &self,
47        url: &str,
48        params: &[(&str, &str)],
49    ) -> BoxFuture<'_, Result<Response, QobuzApiError>>;
50
51    /// Sends an authenticated GET request with optional Range header.
52    ///
53    /// # Arguments
54    ///
55    /// * `url` - Full URL to send the request to
56    /// * `token` - Bearer authentication token
57    /// * `range` - Optional Range header value (e.g., `"bytes=1024-"`)
58    ///
59    /// # Returns
60    ///
61    /// The HTTP response.
62    fn get_with_auth(
63        &self,
64        url: &str,
65        token: &str,
66        range: Option<&str>,
67    ) -> BoxFuture<'_, Result<Response, QobuzApiError>>;
68}
69
70/// Production HTTP client wrapping `reqwest::Client`.
71pub struct ReqwestClient {
72    /// Inner reqwest client with connection pooling.
73    inner: Client,
74}
75
76impl ReqwestClient {
77    /// Creates a new `ReqwestClient` with default headers and connection pooling.
78    ///
79    /// # Arguments
80    ///
81    /// * `app_id` - Qobuz application ID sent as `x-app-id` header on every request
82    ///
83    /// # Returns
84    ///
85    /// A `ReqwestClient` ready for making HTTP requests.
86    ///
87    /// # Errors
88    ///
89    /// Returns a `QobuzApiError` if the reqwest client builder fails.
90    pub fn new(app_id: &str) -> Result<Self, QobuzApiError> {
91        let mut default_headers = HeaderMap::new();
92        if let Ok(val) = HeaderValue::from_str(app_id) {
93            default_headers.insert(HeaderName::from_static("x-app-id"), val);
94        }
95
96        let inner = Client::builder()
97            .user_agent(
98                "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/110.0",
99            )
100            .default_headers(default_headers)
101            .build()?;
102
103        Ok(Self { inner })
104    }
105
106    /// Boxes this client as a `dyn HttpClient`.
107    ///
108    /// # Returns
109    ///
110    /// A `Box<dyn HttpClient>` suitable for trait-object dispatch.
111    #[must_use]
112    pub fn into_boxed(self) -> Box<dyn HttpClient> {
113        Box::new(self)
114    }
115}
116
117impl HttpClient for ReqwestClient {
118    fn get(
119        &self,
120        url: &str,
121        params: &[(&str, &str)],
122    ) -> BoxFuture<'_, Result<Response, QobuzApiError>> {
123        let fut = self.inner.get(url).query(params).send();
124        Box::pin(async move {
125            let resp = fut.await?;
126            Ok(resp)
127        })
128    }
129
130    fn post_form(
131        &self,
132        url: &str,
133        params: &[(&str, &str)],
134    ) -> BoxFuture<'_, Result<Response, QobuzApiError>> {
135        let params: Vec<(String, String)> = params
136            .iter()
137            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
138            .collect();
139
140        let fut = self.inner.post(url).form(&params);
141        Box::pin(async move {
142            let resp = fut.send().await?;
143            Ok(resp)
144        })
145    }
146
147    fn get_with_auth(
148        &self,
149        url: &str,
150        token: &str,
151        range: Option<&str>,
152    ) -> BoxFuture<'_, Result<Response, QobuzApiError>> {
153        let mut req = self.inner.get(url).header("X-User-Auth-Token", token);
154
155        if let Some(r) = range {
156            req = req.header("Range", r);
157        }
158
159        let fut = req.send();
160        Box::pin(async move {
161            let resp = fut.await?;
162            Ok(resp)
163        })
164    }
165}