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}