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>
12fn 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 {
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
45pub struct ReqwestClient {
46    client: reqwest::Client,
47}
48
49impl ReqwestClient {
50    #[must_use]
51    /// # Panics
52    /// Panics if the underlying HTTP client cannot be constructed.
53    pub fn new() -> Self {
54        Self {
55            client: reqwest::Client::builder()
56                .no_proxy()
57                .build()
58                .expect("failed to build reqwest client"),
59        }
60    }
61
62    #[must_use]
63    pub fn with_client(client: reqwest::Client) -> Self {
64        Self { client }
65    }
66}
67
68impl Default for ReqwestClient {
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74#[async_trait]
75impl HttpClient for ReqwestClient {
76    async fn get(&self, url: &str) -> Result<serde_json::Value> {
77        let response = self.client.get(url).send().await?;
78        let status = response.status();
79
80        // Check for rate limiting via HTTP 429
81        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
82            // Try to extract Retry-After header
83            let retry_after = response
84                .headers()
85                .get("retry-after")
86                .and_then(|v| v.to_str().ok())
87                .and_then(|v| v.parse::<u64>().ok())
88                .map(Duration::from_secs);
89
90            return Err(LastFmError::RateLimited { retry_after });
91        }
92
93        let body_text = response.text().await?;
94
95        if !status.is_success() {
96            #[cfg(debug_assertions)]
97            eprintln!("HTTP error {status} for URL: {url}\nRaw body:\n{body_text}");
98
99            // Try to parse as Last.fm API error response
100            if let Ok(error) = serde_json::from_str::<LastFmErrorResponse>(&body_text) {
101                let method = extract_method_from_url(url);
102                let retryable = is_api_error_retryable(error.error);
103
104                // Special handling for rate limit error code 29
105                if error.error == 29 {
106                    return Err(LastFmError::RateLimited {
107                        retry_after: Some(Duration::from_secs(60)), // Default 1 minute
108                    });
109                }
110
111                return Err(LastFmError::Api {
112                    method,
113                    message: error.message,
114                    error_code: error.error,
115                    retryable,
116                });
117            }
118
119            return Err(LastFmError::Other(format!(
120                "HTTP {status} with non-JSON body"
121            )));
122        }
123
124        match serde_json::from_str::<serde_json::Value>(&body_text) {
125            Ok(json) => {
126                // Check if the JSON contains an error field (Last.fm returns errors with HTTP 200)
127                if json.get("error").is_some()
128                    && let Ok(error) = serde_json::from_value::<LastFmErrorResponse>(json.clone())
129                {
130                    let method = extract_method_from_url(url);
131                    let retryable = is_api_error_retryable(error.error);
132
133                    // Special handling for rate limit error code 29
134                    if error.error == 29 {
135                        return Err(LastFmError::RateLimited {
136                            retry_after: Some(Duration::from_secs(60)),
137                        });
138                    }
139
140                    return Err(LastFmError::Api {
141                        method,
142                        message: error.message,
143                        error_code: error.error,
144                        retryable,
145                    });
146                }
147
148                Ok(json)
149            }
150            Err(err) => {
151                #[cfg(debug_assertions)]
152                eprintln!("JSON parse failed for URL: {url}\nError: {err}\nBody:\n{body_text}");
153                Err(err.into())
154            }
155        }
156    }
157}
158
159/// Mock HTTP client for testing
160#[derive(Debug, Clone)]
161pub struct MockClient {
162    responses: HashMap<String, serde_json::Value>,
163}
164
165impl MockClient {
166    #[must_use]
167    pub fn new() -> Self {
168        Self {
169            responses: HashMap::new(),
170        }
171    }
172
173    #[must_use]
174    pub fn with_response(mut self, method: &str, data: serde_json::Value) -> Self {
175        self.responses.insert(method.to_string(), data);
176        self
177    }
178}
179
180impl Default for MockClient {
181    fn default() -> Self {
182        Self::new()
183    }
184}
185
186#[async_trait]
187impl HttpClient for MockClient {
188    async fn get(&self, url: &str) -> Result<serde_json::Value> {
189        // Extract method from URL query parameters
190        let url_obj = url::Url::parse(url)
191            .map_err(|e| LastFmError::Other(format!("Invalid URL in mock client: {e}")))?;
192
193        let method = url_obj
194            .query_pairs()
195            .find(|(key, _)| key == "method")
196            .map(|(_, value)| value.to_string())
197            .ok_or_else(|| LastFmError::Other("No method parameter in mock URL".to_string()))?;
198
199        let json =
200            self.responses.get(&method).cloned().ok_or_else(|| {
201                LastFmError::Other(format!("No mock response for method: {method}"))
202            })?;
203
204        // Check if the JSON contains an error field (Last.fm returns errors with HTTP 200)
205        if json.get("error").is_some()
206            && let Ok(error) = serde_json::from_value::<LastFmErrorResponse>(json.clone())
207        {
208            let retryable = is_api_error_retryable(error.error);
209
210            // Special handling for rate limit error code 29
211            if error.error == 29 {
212                return Err(LastFmError::RateLimited {
213                    retry_after: Some(Duration::from_secs(60)),
214                });
215            }
216
217            return Err(LastFmError::Api {
218                method: method.clone(),
219                message: error.message,
220                error_code: error.error,
221                retryable,
222            });
223        }
224
225        Ok(json)
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use serde_json::json;
233
234    #[tokio::test]
235    async fn test_mock_client_with_response() {
236        let mock = MockClient::new().with_response(
237            "user.getrecenttracks",
238            json!({
239                "recenttracks": {
240                    "track": [],
241                    "@attr": {
242                        "user": "test",
243                        "totalPages": "0",
244                        "page": "1",
245                        "perPage": "50",
246                        "total": "0"
247                    }
248                }
249            }),
250        );
251
252        let response = mock
253            .get("http://example.com?method=user.getrecenttracks")
254            .await
255            .unwrap();
256
257        assert!(response.is_object());
258        assert!(response["recenttracks"].is_object());
259    }
260
261    #[tokio::test]
262    async fn test_mock_client_missing_method() {
263        let mock = MockClient::new();
264
265        let result = mock
266            .get("http://example.com?method=user.getrecenttracks")
267            .await;
268
269        assert!(result.is_err());
270        assert!(matches!(result.unwrap_err(), LastFmError::Other(_)));
271    }
272
273    #[tokio::test]
274    async fn test_mock_client_invalid_url() {
275        let mock = MockClient::new();
276
277        let result = mock.get("not a valid url").await;
278
279        assert!(result.is_err());
280    }
281}