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
7fn 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 {
40 async fn get(&self, url: &str) -> Result<serde_json::Value>;
42}
43
44pub struct ReqwestClient {
46 client: reqwest::Client,
47}
48
49impl ReqwestClient {
50 #[must_use]
51 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 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
82 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 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 if error.error == 29 {
106 return Err(LastFmError::RateLimited {
107 retry_after: Some(Duration::from_secs(60)), });
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::Http {
120 status: status.as_u16(),
121 source: None,
122 });
123 }
124
125 match serde_json::from_str::<serde_json::Value>(&body_text) {
126 Ok(json) => {
127 if json.get("error").is_some()
129 && let Ok(error) = serde_json::from_value::<LastFmErrorResponse>(json.clone())
130 {
131 let method = extract_method_from_url(url);
132 let retryable = is_api_error_retryable(error.error);
133
134 if error.error == 29 {
136 return Err(LastFmError::RateLimited {
137 retry_after: Some(Duration::from_secs(60)),
138 });
139 }
140
141 return Err(LastFmError::Api {
142 method,
143 message: error.message,
144 error_code: error.error,
145 retryable,
146 });
147 }
148
149 Ok(json)
150 }
151 Err(err) => {
152 #[cfg(debug_assertions)]
153 eprintln!("JSON parse failed for URL: {url}\nError: {err}\nBody:\n{body_text}");
154 Err(err.into())
155 }
156 }
157 }
158}
159
160#[derive(Debug, Clone)]
162pub struct MockClient {
163 responses: HashMap<String, serde_json::Value>,
164}
165
166impl MockClient {
167 #[must_use]
168 pub fn new() -> Self {
169 Self {
170 responses: HashMap::new(),
171 }
172 }
173
174 #[must_use]
175 pub fn with_response(mut self, method: &str, data: serde_json::Value) -> Self {
176 self.responses.insert(method.to_string(), data);
177 self
178 }
179}
180
181impl Default for MockClient {
182 fn default() -> Self {
183 Self::new()
184 }
185}
186
187#[async_trait]
188impl HttpClient for MockClient {
189 async fn get(&self, url: &str) -> Result<serde_json::Value> {
190 let url_obj = url::Url::parse(url)?;
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::Config("No method parameter in mock URL".to_string()))?;
198
199 let json =
200 self.responses.get(&method).cloned().ok_or_else(|| {
201 LastFmError::Config(format!("No mock response for method: {method}"))
202 })?;
203
204 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 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::Config(_)));
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}