Skip to main content

lastfm_client/client/
http.rs

1use async_trait::async_trait;
2use std::collections::HashMap;
3use std::time::Duration;
4
5use crate::error::{LastFmError, LastFmErrorResponse, Result};
6
7/// Determine if a Last.fm API error code is retryable
8///
9/// Based on Last.fm API documentation:
10/// - <https://www.last.fm/api/errorcodes>
11/// - <https://lastfm-docs.github.io/api-docs/codes>
12const fn is_api_error_retryable(error_code: u32) -> bool {
13    matches!(
14        error_code,
15        8  // Operation failed (temporary server issue)
16        | 11  // Service Offline (temporary maintenance)
17        | 16  // Temporary error processing request
18        | 29 // Rate limit exceeded
19    )
20}
21
22/// Extract the Last.fm API method name from a URL
23///
24/// Parses the URL query parameters to find the "method" parameter.
25/// Returns "unknown" if the method cannot be extracted.
26fn extract_method_from_url(url: &str) -> String {
27    url::Url::parse(url)
28        .ok()
29        .and_then(|url| {
30            url.query_pairs()
31                .find(|(key, _)| key == "method")
32                .map(|(_, value)| value.to_string())
33        })
34        .unwrap_or_else(|| "unknown".to_string())
35}
36
37/// HTTP client abstraction for making API requests
38#[async_trait]
39pub trait HttpClient: Send + Sync + std::fmt::Debug {
40    /// Perform a GET request and return the response as JSON
41    async fn get(&self, url: &str) -> Result<serde_json::Value>;
42}
43
44/// Production HTTP client using reqwest
45#[derive(Debug)]
46pub struct ReqwestClient {
47    client: reqwest::Client,
48}
49
50impl ReqwestClient {
51    #[must_use]
52    /// # Panics
53    /// Panics if the underlying HTTP client cannot be constructed.
54    #[allow(clippy::expect_used)]
55    pub fn new() -> Self {
56        Self {
57            client: reqwest::Client::builder()
58                .no_proxy()
59                .build()
60                .expect("failed to build reqwest client"),
61        }
62    }
63
64    /// Create a new client with an existing reqwest client
65    #[must_use]
66    pub const fn with_client(client: reqwest::Client) -> Self {
67        Self { client }
68    }
69}
70
71impl Default for ReqwestClient {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77#[async_trait]
78impl HttpClient for ReqwestClient {
79    async fn get(&self, url: &str) -> Result<serde_json::Value> {
80        let response = self.client.get(url).send().await?;
81        let status = response.status();
82
83        // Check for rate limiting via HTTP 429
84        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
85            // Try to extract Retry-After header
86            let retry_after = response
87                .headers()
88                .get("retry-after")
89                .and_then(|v| v.to_str().ok())
90                .and_then(|v| v.parse::<u64>().ok())
91                .map(Duration::from_secs);
92
93            return Err(LastFmError::RateLimited { retry_after });
94        }
95
96        let body_text = response.text().await?;
97
98        if !status.is_success() {
99            #[cfg(debug_assertions)]
100            eprintln!("HTTP error {status} for URL: {url}\nRaw body:\n{body_text}");
101
102            // Try to parse as Last.fm API error response
103            if let Ok(error) = serde_json::from_str::<LastFmErrorResponse>(&body_text) {
104                let method = extract_method_from_url(url);
105                let retryable = is_api_error_retryable(error.error);
106
107                // Special handling for rate limit error code 29
108                if error.error == 29 {
109                    return Err(LastFmError::RateLimited {
110                        retry_after: Some(Duration::from_secs(60)), // Default 1 minute
111                    });
112                }
113
114                return Err(LastFmError::Api {
115                    method,
116                    message: error.message,
117                    error_code: error.error,
118                    retryable,
119                });
120            }
121
122            return Err(LastFmError::Http {
123                status: status.as_u16(),
124                source: None,
125            });
126        }
127
128        match serde_json::from_str::<serde_json::Value>(&body_text) {
129            Ok(json) => {
130                // Check if the JSON contains an error field (Last.fm returns errors with HTTP 200)
131                if json.get("error").is_some()
132                    && let Ok(error) = serde_json::from_value::<LastFmErrorResponse>(json.clone())
133                {
134                    let method = extract_method_from_url(url);
135                    let retryable = is_api_error_retryable(error.error);
136
137                    // Special handling for rate limit error code 29
138                    if error.error == 29 {
139                        return Err(LastFmError::RateLimited {
140                            retry_after: Some(Duration::from_secs(60)),
141                        });
142                    }
143
144                    return Err(LastFmError::Api {
145                        method,
146                        message: error.message,
147                        error_code: error.error,
148                        retryable,
149                    });
150                }
151
152                Ok(json)
153            }
154            Err(err) => {
155                #[cfg(debug_assertions)]
156                eprintln!("JSON parse failed for URL: {url}\nError: {err}\nBody:\n{body_text}");
157                Err(err.into())
158            }
159        }
160    }
161}
162
163/// Mock HTTP client for testing
164#[derive(Debug, Clone)]
165pub struct MockClient {
166    responses: HashMap<String, serde_json::Value>,
167}
168
169impl MockClient {
170    /// Create a new empty mock client
171    #[must_use]
172    pub fn new() -> Self {
173        Self {
174            responses: HashMap::new(),
175        }
176    }
177
178    /// Add a mock response for the given API method
179    #[must_use]
180    pub fn with_response(mut self, method: &str, data: serde_json::Value) -> Self {
181        self.responses.insert(method.to_string(), data);
182        self
183    }
184}
185
186impl Default for MockClient {
187    fn default() -> Self {
188        Self::new()
189    }
190}
191
192#[async_trait]
193impl HttpClient for MockClient {
194    async fn get(&self, url: &str) -> Result<serde_json::Value> {
195        // Extract method from URL query parameters
196        let url_obj = url::Url::parse(url)?;
197
198        let method = url_obj
199            .query_pairs()
200            .find(|(key, _)| key == "method")
201            .map(|(_, value)| value.to_string())
202            .ok_or_else(|| LastFmError::Config("No method parameter in mock URL".to_string()))?;
203
204        let json =
205            self.responses.get(&method).cloned().ok_or_else(|| {
206                LastFmError::Config(format!("No mock response for method: {method}"))
207            })?;
208
209        // Check if the JSON contains an error field (Last.fm returns errors with HTTP 200)
210        if json.get("error").is_some()
211            && let Ok(error) = serde_json::from_value::<LastFmErrorResponse>(json.clone())
212        {
213            let retryable = is_api_error_retryable(error.error);
214
215            // Special handling for rate limit error code 29
216            if error.error == 29 {
217                return Err(LastFmError::RateLimited {
218                    retry_after: Some(Duration::from_secs(60)),
219                });
220            }
221
222            return Err(LastFmError::Api {
223                method,
224                message: error.message,
225                error_code: error.error,
226                retryable,
227            });
228        }
229
230        Ok(json)
231    }
232}
233
234#[cfg(test)]
235#[allow(clippy::unwrap_used)]
236mod tests {
237    use super::*;
238    use serde_json::json;
239
240    #[tokio::test]
241    async fn test_mock_client_with_response() {
242        let mock = MockClient::new().with_response(
243            "user.getrecenttracks",
244            json!({
245                "recenttracks": {
246                    "track": [],
247                    "@attr": {
248                        "user": "test",
249                        "totalPages": "0",
250                        "page": "1",
251                        "perPage": "50",
252                        "total": "0"
253                    }
254                }
255            }),
256        );
257
258        let response = mock
259            .get("http://example.com?method=user.getrecenttracks")
260            .await
261            .unwrap();
262
263        assert!(response.is_object());
264        assert!(response["recenttracks"].is_object());
265    }
266
267    #[tokio::test]
268    async fn test_mock_client_missing_method() {
269        let mock = MockClient::new();
270
271        let result = mock
272            .get("http://example.com?method=user.getrecenttracks")
273            .await;
274
275        assert!(result.is_err());
276        assert!(matches!(result.unwrap_err(), LastFmError::Config(_)));
277    }
278
279    #[tokio::test]
280    async fn test_mock_client_invalid_url() {
281        let mock = MockClient::new();
282
283        let result = mock.get("not a valid url").await;
284
285        assert!(result.is_err());
286    }
287}