lastfm_client/client/lastfm.rs
1use crate::api::{LovedTracksClient, RecentTracksClient, TopTracksClient};
2use crate::client::{
3 HttpClient, RateLimitedClient, RateLimiter, ReqwestClient, RetryClient, RetryPolicy,
4};
5use crate::config::{Config, ConfigBuilder};
6use crate::error::Result;
7use std::sync::Arc;
8
9/// Main Last.fm API client
10///
11/// This is the entry point for interacting with the Last.fm API using the new v2.0 API.
12///
13/// # Example
14/// ```
15/// use lastfm_client::LastFmClient;
16/// use std::time::Duration;
17///
18/// // Create client with custom configuration
19/// let client = LastFmClient::builder()
20/// .api_key("your_api_key")
21/// .timeout(Duration::from_secs(60))
22/// .max_concurrent_requests(10)
23/// .build()
24/// .unwrap();
25///
26/// // Use client.recent_tracks() to fetch data
27/// ```
28pub struct LastFmClient {
29 http: Arc<dyn HttpClient>,
30 config: Arc<Config>,
31 recent_tracks_client: RecentTracksClient,
32 loved_tracks_client: LovedTracksClient,
33 top_tracks_client: TopTracksClient,
34}
35
36impl LastFmClient {
37 /// Create a new configuration builder
38 ///
39 /// This is the recommended way to create a `LastFmClient`.
40 ///
41 /// # Example
42 /// ```no_run
43 /// use lastfm_client::LastFmClient;
44 ///
45 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
46 /// let client = LastFmClient::builder()
47 /// .api_key("your_api_key")
48 /// .build()?;
49 /// # Ok(())
50 /// # }
51 /// ```
52 #[must_use]
53 pub fn builder() -> ConfigBuilder {
54 ConfigBuilder::new()
55 }
56
57 /// Create a new `LastFmClient` with default configuration
58 ///
59 /// This will automatically try to load the API key from the `LAST_FM_API_KEY`
60 /// environment variable. All other settings use sensible defaults.
61 ///
62 /// # Example
63 /// ```no_run
64 /// use lastfm_client::LastFmClient;
65 ///
66 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
67 /// let client = LastFmClient::new()?;
68 /// # Ok(())
69 /// # }
70 /// ```
71 ///
72 /// # Errors
73 /// Returns an error if the API key is not set and cannot be loaded from environment
74 pub fn new() -> Result<Self> {
75 let config = ConfigBuilder::build_with_defaults()?;
76 Ok(Self::from_config(config))
77 }
78
79 /// Create a new `LastFmClient` from a configuration
80 ///
81 /// This automatically sets up retry logic and rate limiting based on the configuration.
82 /// Most users should use `builder()` instead.
83 #[must_use]
84 pub fn from_config(config: Config) -> Self {
85 // Create base HTTP client
86 let base_client = ReqwestClient::new();
87
88 // Build the HTTP client with retry and rate limiting
89 let http: Arc<dyn HttpClient> = if let Some(rate_limit_config) = config.rate_limit() {
90 // With rate limiting
91 let retry_policy = RetryPolicy::exponential(config.retry_attempts());
92 let retry_client = RetryClient::new(base_client, retry_policy);
93
94 let limiter = Arc::new(RateLimiter::new(
95 rate_limit_config.max_requests,
96 rate_limit_config.per_duration,
97 ));
98 Arc::new(RateLimitedClient::new(retry_client, limiter))
99 } else {
100 // Without rate limiting, just retry
101 let retry_policy = RetryPolicy::exponential(config.retry_attempts());
102 Arc::new(RetryClient::new(base_client, retry_policy))
103 };
104
105 let config = Arc::new(config);
106 let recent_tracks_client = RecentTracksClient::new(http.clone(), config.clone());
107 let loved_tracks_client = LovedTracksClient::new(http.clone(), config.clone());
108 let top_tracks_client = TopTracksClient::new(http.clone(), config.clone());
109
110 Self {
111 http,
112 config,
113 recent_tracks_client,
114 loved_tracks_client,
115 top_tracks_client,
116 }
117 }
118
119 /// Create a new `LastFmClient` with a custom HTTP client
120 ///
121 /// This is primarily useful for testing with a mock HTTP client.
122 ///
123 /// # Example
124 /// ```
125 /// use lastfm_client::{LastFmClient, Config, ConfigBuilder};
126 /// use lastfm_client::client::MockClient;
127 /// use std::sync::Arc;
128 ///
129 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
130 /// let config = ConfigBuilder::new()
131 /// .api_key("test_key")
132 /// .build()?;
133 ///
134 /// let mock = MockClient::new();
135 /// let client = LastFmClient::with_http(config, Arc::new(mock));
136 /// # Ok(())
137 /// # }
138 /// ```
139 pub fn with_http(config: Config, http: Arc<dyn HttpClient>) -> Self {
140 let config = Arc::new(config);
141 let recent_tracks_client = RecentTracksClient::new(http.clone(), config.clone());
142 let loved_tracks_client = LovedTracksClient::new(http.clone(), config.clone());
143 let top_tracks_client = TopTracksClient::new(http.clone(), config.clone());
144
145 Self {
146 http,
147 config,
148 recent_tracks_client,
149 loved_tracks_client,
150 top_tracks_client,
151 }
152 }
153
154 /// Get a builder for recent tracks requests
155 ///
156 /// # Example
157 /// ```no_run
158 /// # use lastfm_client::LastFmClient;
159 /// # async fn example(client: LastFmClient) -> Result<(), Box<dyn std::error::Error>> {
160 /// let tracks = client
161 /// .recent_tracks("username")
162 /// .limit(100)
163 /// .fetch()
164 /// .await?;
165 /// # Ok(())
166 /// # }
167 /// ```
168 pub fn recent_tracks(
169 &self,
170 username: impl Into<String>,
171 ) -> crate::api::RecentTracksRequestBuilder {
172 self.recent_tracks_client.builder(username)
173 }
174
175 /// Get a builder for loved tracks requests
176 ///
177 /// # Example
178 /// ```no_run
179 /// # use lastfm_client::LastFmClient;
180 /// # async fn example(client: LastFmClient) -> Result<(), Box<dyn std::error::Error>> {
181 /// let tracks = client
182 /// .loved_tracks("username")
183 /// .limit(100)
184 /// .fetch()
185 /// .await?;
186 /// # Ok(())
187 /// # }
188 /// ```
189 pub fn loved_tracks(
190 &self,
191 username: impl Into<String>,
192 ) -> crate::api::LovedTracksRequestBuilder {
193 self.loved_tracks_client.builder(username)
194 }
195
196 /// Get a builder for top tracks requests
197 ///
198 /// # Example
199 /// ```no_run
200 /// # use lastfm_client::LastFmClient;
201 /// # async fn example(client: LastFmClient) -> Result<(), Box<dyn std::error::Error>> {
202 /// let tracks = client
203 /// .top_tracks("username")
204 /// .limit(100)
205 /// .fetch()
206 /// .await?;
207 /// # Ok(())
208 /// # }
209 /// ```
210 pub fn top_tracks(&self, username: impl Into<String>) -> crate::api::TopTracksRequestBuilder {
211 self.top_tracks_client.builder(username)
212 }
213
214 /// Check if a Last.fm user exists
215 ///
216 /// # Arguments
217 /// * `username` - The Last.fm username to check
218 ///
219 /// # Returns
220 /// * `Ok(true)` - User exists
221 /// * `Ok(false)` - User does not exist
222 /// * `Err` - Network error or other API error
223 ///
224 /// # Example
225 /// ```no_run
226 /// # use lastfm_client::LastFmClient;
227 /// # async fn example(client: LastFmClient) -> Result<(), Box<dyn std::error::Error>> {
228 /// if client.user_exists("rj").await? {
229 /// println!("User exists!");
230 /// } else {
231 /// println!("User not found");
232 /// }
233 /// # Ok(())
234 /// # }
235 /// ```
236 ///
237 /// # Errors
238 /// Returns an error if the request fails due to network issues or other API errors
239 /// (not including "user not found" which returns `Ok(false)`)
240 pub async fn user_exists(&self, username: impl Into<String>) -> Result<bool> {
241 use crate::api::constants::BASE_URL;
242 use crate::error::LastFmError;
243 use crate::url_builder::{QueryParams, Url};
244
245 let username = username.into();
246 let mut params = QueryParams::new();
247 params.insert("method".to_string(), "user.getinfo".to_string());
248 params.insert("user".to_string(), username);
249 params.insert("api_key".to_string(), self.config.api_key().to_string());
250 params.insert("format".to_string(), "json".to_string());
251
252 let url = Url::new(BASE_URL).add_args(params).build();
253
254 match self.http.get(&url).await {
255 Ok(_) => Ok(true),
256 Err(LastFmError::Api { error_code, .. }) if error_code == 6 || error_code == 7 => {
257 // Error code 6: Invalid parameters (user not found)
258 // Error code 7: Invalid resource specified (user not found)
259 Ok(false)
260 }
261 Err(e) => Err(e),
262 }
263 }
264
265 /// Get a reference to the configuration
266 #[must_use]
267 pub fn config(&self) -> &Config {
268 &self.config
269 }
270}
271
272// Convenience: allow building the client directly from the ConfigBuilder
273impl ConfigBuilder {
274 /// Build a `LastFmClient` directly from this builder
275 ///
276 /// This is equivalent to calling `build().map(LastFmClient::from_config)`.
277 ///
278 /// # Errors
279 /// Returns an error if the builder is missing required fields (e.g., API key).
280 pub fn build_client(self) -> Result<LastFmClient> {
281 self.build().map(LastFmClient::from_config)
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use crate::client::MockClient;
289
290 #[test]
291 fn test_client_from_config() {
292 let config = ConfigBuilder::new().api_key("test_key").build().unwrap();
293
294 let client = LastFmClient::from_config(config);
295 assert_eq!(client.config().api_key(), "test_key");
296 }
297
298 #[test]
299 fn test_client_with_mock() {
300 let config = ConfigBuilder::new().api_key("test_key").build().unwrap();
301
302 let mock = MockClient::new();
303 let http = Arc::new(mock);
304 let client = LastFmClient::with_http(config, http);
305 assert_eq!(client.config().api_key(), "test_key");
306 }
307
308 #[test]
309 fn test_builder() {
310 let client = LastFmClient::builder()
311 .api_key("test_key")
312 .build()
313 .map(LastFmClient::from_config)
314 .unwrap();
315
316 assert_eq!(client.config().api_key(), "test_key");
317 }
318
319 #[tokio::test]
320 async fn test_user_exists_returns_true() {
321 use serde_json::json;
322
323 let config = ConfigBuilder::new().api_key("test_key").build().unwrap();
324
325 let mock = MockClient::new().with_response(
326 "user.getinfo",
327 json!({
328 "user": {
329 "name": "rj",
330 "realname": "Richard Jones",
331 "url": "https://www.last.fm/user/rj"
332 }
333 }),
334 );
335
336 let client = LastFmClient::with_http(config, Arc::new(mock));
337 let result = client.user_exists("rj").await;
338
339 assert!(result.is_ok());
340 assert!(result.unwrap());
341 }
342
343 #[tokio::test]
344 async fn test_user_exists_returns_false_for_error_6() {
345 use serde_json::json;
346
347 let config = ConfigBuilder::new().api_key("test_key").build().unwrap();
348
349 // Mock returns error code 6 (Invalid parameters / user not found)
350 let mock = MockClient::new().with_response(
351 "user.getinfo",
352 json!({
353 "error": 6,
354 "message": "User not found"
355 }),
356 );
357
358 let client = LastFmClient::with_http(config, Arc::new(mock));
359 let result = client.user_exists("nonexistentuser").await;
360
361 assert!(result.is_ok());
362 assert!(!result.unwrap());
363 }
364
365 #[tokio::test]
366 async fn test_user_exists_returns_false_for_error_7() {
367 use serde_json::json;
368
369 let config = ConfigBuilder::new().api_key("test_key").build().unwrap();
370
371 // Mock returns error code 7 (Invalid resource specified)
372 let mock = MockClient::new().with_response(
373 "user.getinfo",
374 json!({
375 "error": 7,
376 "message": "Invalid resource specified"
377 }),
378 );
379
380 let client = LastFmClient::with_http(config, Arc::new(mock));
381 let result = client.user_exists("invaliduser").await;
382
383 assert!(result.is_ok());
384 assert!(!result.unwrap());
385 }
386
387 #[tokio::test]
388 async fn test_user_exists_propagates_other_api_errors() {
389 use crate::error::LastFmError;
390 use serde_json::json;
391
392 let config = ConfigBuilder::new().api_key("test_key").build().unwrap();
393
394 // Mock returns error code 10 (Invalid API key)
395 let mock = MockClient::new().with_response(
396 "user.getinfo",
397 json!({
398 "error": 10,
399 "message": "Invalid API key"
400 }),
401 );
402
403 let client = LastFmClient::with_http(config, Arc::new(mock));
404 let result = client.user_exists("someuser").await;
405
406 assert!(result.is_err());
407 let err = result.unwrap_err();
408 assert!(matches!(err, LastFmError::Api { error_code: 10, .. }));
409 }
410}