lastfm_client/client/
http.rs1use async_trait::async_trait;
2use std::collections::HashMap;
3use std::time::Duration;
4
5use crate::error::{LastFmError, LastFmErrorResponse, Result};
6
7const fn is_api_error_retryable(error_code: u32) -> bool {
13 matches!(
14 error_code,
15 8 | 11 | 16 | 29 )
20}
21
22fn 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#[async_trait]
39pub trait HttpClient: Send + Sync + std::fmt::Debug {
40 async fn get(&self, url: &str) -> Result<serde_json::Value>;
42}
43
44#[derive(Debug)]
46pub struct ReqwestClient {
47 client: reqwest::Client,
48}
49
50impl ReqwestClient {
51 #[must_use]
52 #[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 #[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 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
85 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 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 if error.error == 29 {
109 return Err(LastFmError::RateLimited {
110 retry_after: Some(Duration::from_secs(60)), });
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 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 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#[derive(Debug, Clone)]
165pub struct MockClient {
166 responses: HashMap<String, serde_json::Value>,
167}
168
169impl MockClient {
170 #[must_use]
172 pub fn new() -> Self {
173 Self {
174 responses: HashMap::new(),
175 }
176 }
177
178 #[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 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 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 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}