Skip to main content

qobuz_api/api/
http_client.rs

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