diff --git a/examples/customise_client.rs b/examples/customise_client.rs index 59cd92b..200d6a0 100644 --- a/examples/customise_client.rs +++ b/examples/customise_client.rs @@ -1,11 +1,8 @@ use lastfm::ClientBuilder; -use std::env; #[tokio::main] async fn main() -> Result<(), Box> { - let api_key = env::var("LASTFM_API_KEY")?; - - let client = ClientBuilder::new(api_key, "loige") + let client = ClientBuilder::new("some-api-key", "loige") .reqwest_client(reqwest::Client::new()) .base_url("http://localhost:8080".parse().unwrap()) .build(); diff --git a/src/artist.rs b/src/artist.rs index ea1596c..6c23dee 100644 --- a/src/artist.rs +++ b/src/artist.rs @@ -1,7 +1,10 @@ +//! # Artist +//! +//! defines the [`Artist`] struct and its methods. +use crate::imageset::ImageSet; use serde::{Deserialize, Serialize}; -use super::imageset::ImageSet; - +/// A Last.fm artist. #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash)] pub struct Artist { pub image: ImageSet, diff --git a/src/client.rs b/src/client.rs index 110b4e9..4b9fe41 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,18 +1,26 @@ -use super::{ +//! # Client +//! +//! The main client module for the Last.fm API. +//! +//! This module contains the [`Client`] struct and its methods. +//! It also provides a [`ClientBuilder`] to create a new [`Client`]. +use crate::{ errors::Error, - recent_tracks_page::RecentTracksPage, + recent_tracks_page::{RecentTracksPage, RecentTracksResponse}, + retry_strategy::{JitteredBackoff, RetryStrategy}, track::{NowPlayingTrack, RecordedTrack, Track}, }; -use crate::{recent_tracks_page::RecentTracksResponse, retry_delay::RetryDelay}; use async_stream::try_stream; use std::{ env::{self, VarError}, + fmt::Debug, time::Duration, }; use tokio_stream::Stream; use url::Url; -const BASE_URL: &str = "https://ws.audioscrobbler.com/2.0/"; +/// The default base URL for the Last.fm API. +pub const DEFAULT_BASE_URL: &str = "https://ws.audioscrobbler.com/2.0/"; lazy_static! { static ref DEFAULT_CLIENT: reqwest::Client = reqwest::ClientBuilder::new() @@ -26,42 +34,76 @@ lazy_static! { .expect("Cannot initialize HTTP client"); } +/// Utility function that masks the API key by replacing all but the first 3 characters with `*`. +fn mask_api_key(api_key: &str) -> String { + api_key + .chars() + .enumerate() + .map(|(i, c)| match i { + 0 | 1 | 2 => c, + _ => '*', + }) + .collect() +} + +/// A builder for the [`Client`] struct. pub struct ClientBuilder { api_key: String, username: String, reqwest_client: Option, base_url: Option, + retry_strategy: Option>, } impl ClientBuilder { + /// Creates a new [`ClientBuilder`] with the given API key and username. pub fn new, U: AsRef>(api_key: A, username: U) -> Self { Self { api_key: api_key.as_ref().to_string(), username: username.as_ref().to_string(), reqwest_client: None, base_url: None, + retry_strategy: None, } } + /// Creates a new [`ClientBuilder`] with the given username. + /// This is a shortcut for [`ClientBuilder::try_from_env`] that panics instead of returning an error. + /// + /// # Panics + /// This methods expects the `LASTFM_API_KEY` environment variable to be set and it would panic otherwise. pub fn from_env>(username: U) -> Self { - Self::try_from_env(username).unwrap() + Self::try_from_env(username).expect("Cannot read LASTFM_API_KEY from environment") } + /// Creates a new [`ClientBuilder`] with the given username. + /// This methods expects the `LASTFM_API_KEY` environment variable to be set and it would return an error otherwise. pub fn try_from_env>(username: U) -> Result { let api_key = env::var("LASTFM_API_KEY")?; Ok(ClientBuilder::new(api_key, username)) } + /// Sets the [`reqwest::Client`] to use for the requests. pub fn reqwest_client(mut self, client: reqwest::Client) -> Self { self.reqwest_client = Some(client); self } + /// Sets the base URL for the Last.fm API. pub fn base_url(mut self, base_url: Url) -> Self { self.base_url = Some(base_url); self } + /// Sets the retry strategy to use for the requests. + /// + /// For more details on how you can create a custom retry strategy, consult the [`crate::retry_strategy::RetryStrategy`] trait. + pub fn retry_strategy(mut self, retry_strategy: Box) -> Self { + self.retry_strategy = Some(retry_strategy); + self + } + + /// Builds the [`Client`] instance. pub fn build(self) -> Client { Client { api_key: self.api_key, @@ -69,28 +111,49 @@ impl ClientBuilder { reqwest_client: self .reqwest_client .unwrap_or_else(|| DEFAULT_CLIENT.clone()), - base_url: self.base_url.unwrap_or_else(|| BASE_URL.parse().unwrap()), + base_url: self + .base_url + .unwrap_or_else(|| DEFAULT_BASE_URL.parse().unwrap()), + retry_strategy: self + .retry_strategy + .unwrap_or_else(|| Box::from(JitteredBackoff::default())), } } } -#[derive(Debug, Clone)] +/// A client for the Last.fm API. pub struct Client { api_key: String, username: String, reqwest_client: reqwest::Client, base_url: Url, + retry_strategy: Box, } +impl Debug for Client { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Client") + .field("api_key", &mask_api_key(&self.api_key).as_str()) + .field("username", &self.username) + .field("reqwest_client", &self.reqwest_client) + .field("base_url", &self.base_url) + .finish() + } +} + +/// Structs that can be used to get a stream of [`RecordedTrack`]s. +#[non_exhaustive] pub struct RecentTracksFetcher { api_key: String, username: String, current_page: Vec, from: Option, to: Option, + /// The total number of tracks available in the stream. pub total_tracks: u64, reqwest_client: reqwest::Client, base_url: Url, + retry_strategy: Box, } impl RecentTracksFetcher { @@ -109,6 +172,7 @@ impl RecentTracksFetcher { self.to = to; } + /// Converts the current instance into a stream of [`RecordedTrack`]s. pub fn into_stream(mut self) -> impl Stream> { let recent_tracks = try_stream! { loop { @@ -117,7 +181,16 @@ impl RecentTracksFetcher { yield t; } None => { - let next_page = get_page(&self.reqwest_client, &self.base_url, &self.api_key, &self.username, 200, self.from, self.to).await?; + let next_page = get_page(GetPageOptions { + client: &self.reqwest_client, + retry_strategy: &*self.retry_strategy, + base_url: &self.base_url, + api_key: &self.api_key, + username: &self.username, + limit: 200, + from: self.from, + to: self.to + }).await?; if next_page.tracks.is_empty() { break; } @@ -131,38 +204,43 @@ impl RecentTracksFetcher { } } -async fn get_page, U: AsRef>( - client: &reqwest::Client, - base_url: &Url, - api_key: A, - username: U, +/// Configuration options used for the [`Client::get_page`] function. +struct GetPageOptions<'a> { + client: &'a reqwest::Client, + retry_strategy: &'a dyn RetryStrategy, + base_url: &'a Url, + api_key: &'a str, + username: &'a str, limit: u32, from: Option, to: Option, -) -> Result { +} + +/// Gets a page of tracks from the Last.fm API. +async fn get_page(options: GetPageOptions<'_>) -> Result { let mut url_query = vec![ ("method", "user.getrecenttracks".to_string()), - ("user", username.as_ref().to_string()), + ("user", options.username.to_string()), ("format", "json".to_string()), ("extended", "1".to_string()), - ("limit", limit.to_string()), - ("api_key", api_key.as_ref().to_string()), + ("limit", options.limit.to_string()), + ("api_key", options.api_key.to_string()), ]; - if let Some(from) = from { + if let Some(from) = options.from { url_query.push(("from", from.to_string())); } - if let Some(to) = to { + if let Some(to) = options.to { url_query.push(("to", to.to_string())); } - let url = Url::parse_with_params(base_url.as_str(), &url_query).unwrap(); + let url = Url::parse_with_params(options.base_url.as_str(), &url_query).unwrap(); - let retry = RetryDelay::default(); let mut errors: Vec = Vec::new(); - for sleep_time in retry { - let res = client.get(&(url).to_string()).send().await; + let mut num_retries: usize = 0; + while let Some(retry_delay) = options.retry_strategy.should_retry_after(num_retries) { + let res = options.client.get(&(url).to_string()).send().await; match res { Ok(res) => { let page: RecentTracksResponse = res.json().await?; @@ -175,40 +253,52 @@ async fn get_page, U: AsRef>( if !e.is_retriable() { return Err(e.into()); } - tokio::time::sleep(sleep_time).await; + tokio::time::sleep(retry_delay).await; } } } Err(e) => { tracing::error!("Error: {}", e); errors.push(e.into()); - tokio::time::sleep(sleep_time).await; + tokio::time::sleep(retry_delay).await; } } + num_retries += 1; } Err(Error::TooManyRetry(errors)) } impl Client { + /// Creates a new [`Client`] with the given username. + /// The API key is read from the `LASTFM_API_KEY` environment variable. + /// This method is a shortcut for [`ClientBuilder::from_env`] but, in case of failure, it will panic rather than returning an error. + /// + /// # Panics + /// If the environment variable is not set, this function will panic. pub fn from_env>(username: U) -> Self { ClientBuilder::try_from_env(username).unwrap().build() } + /// Creates a new [`Client`] with the given username. + /// The API key is read from the `LASTFM_API_KEY` environment variable. + /// If the environment variable is not set, this function will return an error. pub fn try_from_env>(username: U) -> Result { Ok(ClientBuilder::try_from_env(username)?.build()) } + /// Fetches the currently playing track for the user (if any) pub async fn now_playing(&self) -> Result, Error> { - let page = get_page( - &self.reqwest_client, - &self.base_url, - &self.api_key, - &self.username, - 1, - None, - None, - ) + let page = get_page(GetPageOptions { + client: &self.reqwest_client, + retry_strategy: &*self.retry_strategy, + base_url: &self.base_url, + api_key: &self.api_key, + username: &self.username, + limit: 1, + from: None, + to: None, + }) .await?; match page.tracks.first() { @@ -217,24 +307,29 @@ impl Client { } } + /// Creates a new [`RecentTracksFetcher`] that can be used to fetch all of the user's recent tracks. pub async fn all_tracks(self) -> Result { self.recent_tracks(None, None).await } + /// Creates a new [`RecentTracksFetcher`] that can be used to fetch the user's recent tracks in a given time range. + /// + /// The `from` and `to` parameters are Unix timestamps (in seconds). pub async fn recent_tracks( self, from: Option, to: Option, ) -> Result { - let page = get_page( - &self.reqwest_client, - &self.base_url, - &self.api_key, - &self.username, - 200, + let page = get_page(GetPageOptions { + client: &self.reqwest_client, + retry_strategy: &*self.retry_strategy, + base_url: &self.base_url, + api_key: &self.api_key, + username: &self.username, + limit: 200, from, to, - ) + }) .await?; let mut fetcher = RecentTracksFetcher { @@ -246,6 +341,7 @@ impl Client { total_tracks: page.total_tracks, reqwest_client: self.reqwest_client, base_url: self.base_url, + retry_strategy: self.retry_strategy, }; fetcher.update_current_page(page); diff --git a/src/error_response.rs b/src/error_response.rs index fd9b30a..77f3041 100644 --- a/src/error_response.rs +++ b/src/error_response.rs @@ -1,6 +1,10 @@ +//! # Error response +//! +//! Error response from Last.fm API use serde::{Deserialize, Serialize}; use std::{error::Error, fmt::Display}; +/// Error response from Last.fm API #[derive(Debug, Serialize, Deserialize)] pub struct ErrorResponse { pub error: u32, @@ -8,6 +12,9 @@ pub struct ErrorResponse { } impl ErrorResponse { + /// determine if the error is retriable (there is a chance it will work if retried) or not. + /// + /// See for more details. pub fn is_retriable(&self) -> bool { // 2 : Invalid service - This service does not exist // 3 : Invalid Method - No method with that name in this package diff --git a/src/errors.rs b/src/errors.rs index aedeece..d03b353 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -1,6 +1,10 @@ -use super::error_response::ErrorResponse; +//! # Errors +//! +//! Errors that can occur when interacting with the LastFM Client. +use crate::error_response::ErrorResponse; use thiserror::Error; +/// Errors that can occur when interacting with the LastFM Client. #[derive(Error, Debug)] pub enum Error { #[error("HTTP error: {0}")] diff --git a/src/imageset.rs b/src/imageset.rs index 1d0a835..28d1ad4 100644 --- a/src/imageset.rs +++ b/src/imageset.rs @@ -1,7 +1,10 @@ -use std::collections::HashMap; - +//! # Image set +//! +//! defines the [`ImageSet`] struct and its methods. use serde::{de::Error, Deserialize, Deserializer, Serialize}; +use std::collections::HashMap; +/// A set of images for a Last.fm entity. #[derive(Serialize, Debug, Clone, PartialEq, Eq, Hash)] pub struct ImageSet { pub small: Option, diff --git a/src/lfm_date.rs b/src/lfm_date.rs index 906ea30..93e05b4 100644 --- a/src/lfm_date.rs +++ b/src/lfm_date.rs @@ -1,8 +1,11 @@ -use std::{collections::HashMap, ops::Deref}; - +//! # Last.fm Date +//! +//! Defines the [`LfmDate`] struct and its methods. use chrono::{DateTime, LocalResult, TimeZone, Utc}; use serde::{de::Error, Deserialize, Deserializer, Serialize}; +use std::{collections::HashMap, ops::Deref}; +/// A Last.fm date. #[derive(Serialize, Debug, Clone)] pub struct LfmDate(DateTime); diff --git a/src/lib.rs b/src/lib.rs index 10be008..1b10e6a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -124,6 +124,7 @@ pub mod errors; pub mod imageset; pub mod lfm_date; pub mod recent_tracks_page; -pub mod retry_delay; +pub mod retry_strategy; pub mod track; pub use client::{Client, ClientBuilder}; +pub use reqwest; diff --git a/src/recent_tracks_page.rs b/src/recent_tracks_page.rs index 5a449c3..a2c4100 100644 --- a/src/recent_tracks_page.rs +++ b/src/recent_tracks_page.rs @@ -1,7 +1,11 @@ -use super::{error_response::ErrorResponse, track::Track}; +//! # Recent tracks page +//! +//! Defines the [`RecentTracksPage`] struct and its methods. +use crate::{error_response::ErrorResponse, track::Track}; use serde::{de::Error, Deserialize, Deserializer, Serialize}; use serde_json::Value; +/// A Last.fm recent tracks response. Can either be an error or an actual page of recent tracks. #[derive(Serialize, Deserialize)] #[serde(untagged)] pub enum RecentTracksResponse { @@ -9,6 +13,7 @@ pub enum RecentTracksResponse { RecentTracksPage(RecentTracksPage), } +/// A Last.fm recent tracks page. #[derive(Serialize, Debug, Clone)] pub struct RecentTracksPage { pub total_tracks: u64, diff --git a/src/retry_delay.rs b/src/retry_delay.rs deleted file mode 100644 index c826b2c..0000000 --- a/src/retry_delay.rs +++ /dev/null @@ -1,63 +0,0 @@ -use std::time::Duration; - -#[derive(Debug, Clone)] - -pub(crate) struct RetryDelay { - n: usize, - max_retry: usize, -} - -impl RetryDelay { - pub fn new(max_retry: usize) -> Self { - Self { n: 0, max_retry } - } -} - -impl Default for RetryDelay { - fn default() -> Self { - RetryDelay::new(5) - } -} - -impl Iterator for RetryDelay { - type Item = Duration; - - fn next(&mut self) -> Option { - if self.max_retry == self.n { - return None; - } - - if self.n == 0 { - self.n += 1; - return Some(Duration::from_millis(0)); - } - - let jitter = rand::random::() % 1000; - let value = Duration::from_millis(2_u64.pow(self.n as u32) * 1000 - jitter); - - self.n += 1; - - Some(value) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_retry_delay() { - let mut delay = RetryDelay::new(5); - let n = delay.next().unwrap(); - assert_eq!(n, Duration::from_millis(0)); - let n = delay.next().unwrap(); - assert!(n < Duration::from_millis(2000)); - let n = delay.next().unwrap(); - assert!(n < Duration::from_millis(4000)); - let n = delay.next().unwrap(); - assert!(n < Duration::from_millis(8000)); - let n = delay.next().unwrap(); - assert!(n < Duration::from_millis(16000)); - assert!(delay.next().is_none()); - } -} diff --git a/src/retry_strategy.rs b/src/retry_strategy.rs new file mode 100644 index 0000000..2b7184c --- /dev/null +++ b/src/retry_strategy.rs @@ -0,0 +1,91 @@ +//! # Retry strategy +//! +//! The default retry strategy and how to write your own custom retry logic. +use std::time::Duration; + +/// Trait to define a retry strategy +pub trait RetryStrategy { + /// This function is called every time a request to the remote Last.fm APIs fails + /// to determine if a retry attempt should be made and how much time to wait + /// before the next attempt is made. + /// + /// When this function returns `None` there will be no more retries and the execution fails. + /// When this function returns `Some(duration)` the client will wait as long as the specified duration + /// before performing the request again. + /// + /// You could write a very simple retry strategy that always retries immediately as follow: + /// + /// ```rust + /// use lastfm::retry_strategy::RetryStrategy; + /// use std::time::Duration; + /// + /// struct AlwaysRetry {} + /// + /// impl RetryStrategy for AlwaysRetry { + /// fn should_retry_after(&self, attempt: usize) -> Option { + /// Some(Duration::from_secs(0)) + /// } + /// } + /// ``` + /// + /// Or a strategy to never retry + /// + /// ```rust + /// use lastfm::retry_strategy::RetryStrategy; + /// use std::time::Duration; + /// + /// struct NeverRetry {} + /// + /// impl RetryStrategy for NeverRetry { + /// fn should_retry_after(&self, attempt: usize) -> Option { + /// None + /// } + /// } + /// ``` + /// + /// Check out the [`JitteredBackoff`] retry strategy + /// and the `examples` folder for more examples. + fn should_retry_after(&self, attempt: usize) -> Option; +} + +/// The default retry strategy. +/// +/// It performs a Jittered backoff for a given maximum number of times. +/// +/// The wait duration is calculated using the formula (in milliseconds): +/// +/// ```plain +/// 2 ^ (num_retry) * 1000 - random_jitter +/// ``` +/// +/// Where `random_jitter` is a random number between `0` and `999`. +pub struct JitteredBackoff { + /// The maximum number of retries before giving up + max_retry: usize, +} + +impl JitteredBackoff { + pub fn new(max_retry: usize) -> Self { + Self { max_retry } + } +} + +impl RetryStrategy for JitteredBackoff { + fn should_retry_after(&self, num_retry: usize) -> Option { + if self.max_retry == num_retry { + return None; + } + + let jitter = rand::random::() % 1000; + let value = Duration::from_millis(2_u64.pow(num_retry as u32) * 1000 - jitter); + + Some(value) + } +} + +/// Creates a new [`JitteredBackoff`] with the default maximum number of retries (5). +impl Default for JitteredBackoff { + fn default() -> Self { + JitteredBackoff::new(5) + } +} diff --git a/src/track.rs b/src/track.rs index 17b0996..81bdcd6 100644 --- a/src/track.rs +++ b/src/track.rs @@ -1,17 +1,20 @@ +//! # Tracks +//! +//! Defines the [`Track`] struct and its methods. +use crate::artist::Artist; +use crate::imageset::ImageSet; use chrono::{DateTime, LocalResult, TimeZone, Utc}; use serde::{de::Error, Deserialize, Deserializer, Serialize}; use serde_json::{Map, Value}; -use crate::artist::Artist; - -use super::imageset::ImageSet; - +/// A Last.fm track (can either be currently playing or recorded) #[derive(Serialize, Debug, Clone, PartialEq, Eq, Hash)] pub enum Track { NowPlaying(NowPlayingTrack), Recorded(RecordedTrack), } +/// A Last.fm track that is currently playing. #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash)] pub struct NowPlayingTrack { pub artist: Artist, @@ -21,6 +24,7 @@ pub struct NowPlayingTrack { pub url: String, } +/// A Last.fm track that has been recorded. #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash)] pub struct RecordedTrack { pub artist: Artist,