Skip to main content

rspotify_s/
lib.rs

1//! RSpotify is a wrapper for the [Spotify Web API][spotify-main], inspired by
2//! [spotipy][spotipy-github]. It includes support for all the [authorization
3//! flows][spotify-auth-flows], and helper methods for [all available
4//! endpoints][spotify-reference].
5//!
6//! ## Configuration
7//!
8//! ### HTTP Client
9//!
10//! By default, RSpotify uses the [reqwest][reqwest-docs] asynchronous HTTP
11//! client with its default TLS, but you can customize both the HTTP client and
12//! the TLS with the following features:
13//!
14//! - [reqwest][reqwest-docs]: enabling
15//!   `client-reqwest`, TLS available:
16//!     + `reqwest-default-tls` (reqwest's default)
17//!     + `reqwest-rustls-tls`
18//!     + `reqwest-native-tls`
19//!     + `reqwest-native-tls-vendored`
20//! - [ureq][ureq-docs]: enabling `client-ureq`, TLS
21//!   available:
22//!     + `ureq-rustls-tls` (ureq's default)
23//!     + `ureq-rustls-tls-native-certs` (`rustls` with OS root certificates)
24//!
25//! If you want to use a different client or TLS than the default ones, you'll
26//! have to disable the default features and enable whichever you want. For
27//! example, this would compile RSpotify with `reqwest` and the native TLS:
28//!
29//! ```toml
30//! [dependencies]
31//! rspotify = {
32//!     version = "...",
33//!     default-features = false,
34//!     features = ["client-reqwest", "reqwest-native-tls"]
35//! }
36//! ```
37//!
38//! [`maybe_async`] internally enables RSpotify to  use both synchronous and
39//! asynchronous HTTP clients. You can also use `ureq`, a synchronous client,
40//! like so:
41//!
42//! ```toml
43//! [dependencies]
44//! rspotify = {
45//!     version = "...",
46//!     default-features = false,
47//!     features = ["client-ureq", "ureq-rustls-tls"]
48//! }
49//! ```
50//!
51//! ### Proxies
52//!
53//! Both [reqwest][reqwest-proxies] and [ureq][ureq-proxying] support system
54//! proxies by default. They both read `http_proxy`, `https_proxy`, `all_proxy`
55//! and their uppercase variants `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`,
56//! although the specific logic implementations are a little different.
57//!
58//! See also:
59//! - [reqwest](https://docs.rs/reqwest/latest/src/reqwest/proxy.rs.html#897-920)
60//! - [ureq](https://docs.rs/ureq/latest/src/ureq/proxy.rs.html#73-95)
61//!
62//! ### Environmental variables
63//!
64//! RSpotify supports the `dotenvy` crate, which allows you to save credentials
65//! in a `.env` file. These will then be automatically available as
66//! environmental values when using methods like [`Credentials::from_env`].
67//!
68//! ```toml
69//! [dependencies]
70//! rspotify = { version = "...", features = ["env-file"] }
71//! ```
72//!
73//! ### CLI utilities
74//!
75//! RSpotify includes basic support for Cli apps to obtain access tokens by
76//! prompting the user, after enabling the `cli` feature. See the
77//! [Authorization](#authorization) section for more information.
78//!
79//! ## Getting Started
80//!
81//! ### Authorization
82//!
83//! All endpoints require app authorization; you will need to generate a token
84//! that indicates that the client has been granted permission to perform
85//! requests. You can start by [registering your app to get the necessary client
86//! credentials][spotify-register-app]. Read the [official guide for a detailed
87//! explanation of the different authorization flows
88//! available][spotify-auth-flows].
89//!
90//! RSpotify has a different client for each of the available authentication
91//! flows. They may implement the endpoints in
92//! [`BaseClient`](crate::clients::BaseClient) or
93//! [`OAuthClient`](crate::clients::OAuthClient) according to what kind of
94//! flow it is. Please refer to their documentation for more details:
95//!
96//! * [Client Credentials Flow][spotify-client-creds]: see
97//!   [`ClientCredsSpotify`].
98//! * [Authorization Code Flow][spotify-auth-code]: see [`AuthCodeSpotify`].
99//! * [Authorization Code Flow with Proof Key for Code Exchange
100//!   (PKCE)][spotify-auth-code-pkce]: see [`AuthCodePkceSpotify`].
101//! * [Implicit Grant Flow][spotify-implicit-grant]: unimplemented, as RSpotify
102//!   has not been tested on a browser yet. If you'd like support for it, let us
103//!   know in an issue!
104//!
105//! In order to help other developers to get used to `rspotify`, there are
106//! public credentials available for a dummy account. You can test `rspotify`
107//! with this account's `RSPOTIFY_CLIENT_ID` and `RSPOTIFY_CLIENT_SECRET` inside
108//! the [`.env` file](https://github.com/ramsayleung/rspotify/blob/master/.env)
109//! for more details.
110//!
111//! ### WebAssembly
112//!
113//! RSpotify supports the `wasm32-unknown-unknown` target in combination
114//! with the `client-reqwest` feature. HTTP requests must be processed async.
115//! Other HTTP client configurations are not supported.
116//!
117//! [Spotify recommends][spotify-auth-flows] using [`AuthCodePkceSpotify`] for
118//! authorization flows on the web.
119//!
120//! Importing the Client ID via `RSPOTIFY_CLIENT_ID` is not possible since WASM
121//! web runtimes are isolated from the host environment. The client ID must be
122//! passed explicitly to [`Credentials::new_pkce`]. Alternatively, it can be
123//! embedded at compile time with the [`std::env!`] or
124//! [`dotenv!`](https://crates.io/crates/dotenvy) macros.
125//!
126//! ### Examples
127//!
128//! There are some [available examples on the GitHub
129//! repository][examples-github] which can serve as a learning tool.
130//!
131//! [spotipy-github]: https://github.com/plamere/spotipy
132//! [reqwest-docs]: https://docs.rs/reqwest/
133//! [reqwest-proxies]: https://docs.rs/reqwest/#proxies
134//! [ureq-docs]: https://docs.rs/ureq/
135//! [examples-github]: https://github.com/ramsayleung/rspotify/tree/master/examples
136//! [spotify-main]: https://developer.spotify.com/documentation/web-api/
137//! [spotify-auth-flows]: https://developer.spotify.com/documentation/general/guides/authorization/
138//! [spotify-reference]: https://developer.spotify.com/documentation/web-api/reference/
139//! [spotify-register-app]: https://developer.spotify.com/dashboard/applications
140//! [spotify-client-creds]: https://developer.spotify.com/documentation/general/guides/authorization/client-credentials/
141//! [spotify-auth-code]: https://developer.spotify.com/documentation/general/guides/authorization/code-flow
142//! [spotify-auth-code-pkce]: https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow
143//! [spotify-implicit-grant]: https://developer.spotify.com/documentation/general/guides/authorization/implicit-grant
144
145mod auth_code;
146mod auth_code_pkce;
147mod client_creds;
148pub mod clients;
149pub mod sync;
150mod util;
151
152// Subcrate re-exports
153pub use rspotify_http as http;
154pub use rspotify_macros as macros;
155pub use rspotify_model as model;
156// Top-level re-exports
157pub use auth_code::AuthCodeSpotify;
158pub use auth_code_pkce::AuthCodePkceSpotify;
159pub use client_creds::ClientCredsSpotify;
160pub use macros::scopes;
161pub use model::Token;
162
163use crate::{http::HttpError, model::Id};
164
165use std::{
166    collections::{HashMap, HashSet},
167    env, fmt,
168    path::PathBuf,
169    sync::Arc,
170};
171
172use base64::{engine::general_purpose, Engine as _};
173use getrandom::getrandom;
174use thiserror::Error;
175
176pub mod prelude {
177    pub use crate::clients::{BaseClient, OAuthClient};
178    pub use crate::model::idtypes::{Id, PlayContextId, PlayableId};
179}
180
181/// Common headers as constants.
182pub(crate) mod params {
183    pub const CLIENT_ID: &str = "client_id";
184    pub const CODE: &str = "code";
185    pub const GRANT_TYPE: &str = "grant_type";
186    pub const GRANT_TYPE_AUTH_CODE: &str = "authorization_code";
187    pub const GRANT_TYPE_CLIENT_CREDS: &str = "client_credentials";
188    pub const GRANT_TYPE_REFRESH_TOKEN: &str = "refresh_token";
189    pub const REDIRECT_URI: &str = "redirect_uri";
190    pub const REFRESH_TOKEN: &str = "refresh_token";
191    pub const RESPONSE_TYPE_CODE: &str = "code";
192    pub const RESPONSE_TYPE: &str = "response_type";
193    pub const SCOPE: &str = "scope";
194    pub const SHOW_DIALOG: &str = "show_dialog";
195    pub const STATE: &str = "state";
196    pub const CODE_CHALLENGE: &str = "code_challenge";
197    pub const CODE_VERIFIER: &str = "code_verifier";
198    pub const CODE_CHALLENGE_METHOD: &str = "code_challenge_method";
199    pub const CODE_CHALLENGE_METHOD_S256: &str = "S256";
200}
201
202/// Common alphabets for random number generation and similars
203pub(crate) mod alphabets {
204    pub const ALPHANUM: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
205    /// From <https://datatracker.ietf.org/doc/html/rfc7636#section-4.1>
206    pub const PKCE_CODE_VERIFIER: &[u8] =
207        b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~";
208}
209
210pub(crate) mod auth_urls {
211    pub const AUTHORIZE: &str = "authorize";
212    pub const TOKEN: &str = "api/token";
213}
214
215/// Possible errors returned from the `rspotify` client.
216#[derive(Debug, Error)]
217pub enum ClientError {
218    #[error("json parse error: {0}")]
219    ParseJson(#[from] serde_json::Error),
220
221    #[error("url parse error: {0}")]
222    ParseUrl(#[from] url::ParseError),
223
224    // Note that this type is boxed because its size might be very large in
225    // comparison to the rest. For more information visit:
226    // https://rust-lang.github.io/rust-clippy/master/index.html#large_enum_variant
227    #[error("http error: {0}")]
228    Http(Box<HttpError>),
229
230    #[error("input/output error: {0}")]
231    Io(#[from] std::io::Error),
232
233    #[cfg(feature = "cli")]
234    #[error("cli error: {0}")]
235    Cli(String),
236
237    #[error("cache file error: {0}")]
238    CacheFile(String),
239
240    #[error("token callback function error: {0}")]
241    TokenCallbackFn(#[from] CallbackError),
242
243    #[error("model error: {0}")]
244    Model(#[from] model::ModelError),
245
246    #[error("Token is not valid")]
247    InvalidToken,
248}
249
250// The conversion has to be done manually because it's in a `Box<T>`
251impl From<HttpError> for ClientError {
252    fn from(err: HttpError) -> Self {
253        Self::Http(Box::new(err))
254    }
255}
256
257pub type ClientResult<T> = Result<T, ClientError>;
258
259pub const DEFAULT_API_BASE_URL: &str = "https://api.spotify.com/v1/";
260pub const DEFAULT_AUTH_BASE_URL: &str = "https://accounts.spotify.com/";
261pub const DEFAULT_CACHE_PATH: &str = ".spotify_token_cache.json";
262pub const DEFAULT_PAGINATION_CHUNKS: u32 = 50;
263
264#[derive(Error, Debug)]
265pub enum CallbackError {
266    #[error("The callback function raises an error: `{0}`")]
267    CustomizedError(String),
268}
269
270/// A callback function is invokved whenever successfully request or refetch a new token.
271pub struct TokenCallback(pub Box<dyn Fn(Token) -> Result<(), CallbackError> + Send + Sync>);
272
273impl fmt::Debug for TokenCallback {
274    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
275        f.write_str("TokenCallback")
276    }
277}
278
279/// Struct to configure the Spotify client.
280#[derive(Debug, Clone)]
281pub struct Config {
282    /// The Spotify API prefix, [`DEFAULT_API_BASE_URL`] by default.
283    pub api_base_url: String,
284
285    /// The Spotify Authentication prefix, [`DEFAULT_AUTH_BASE_URL`] by default.
286    pub auth_base_url: String,
287
288    /// The cache file path, in case it's used. By default it's
289    /// [`DEFAULT_CACHE_PATH`]
290    pub cache_path: PathBuf,
291
292    /// The pagination chunk size used when performing automatically paginated
293    /// requests, like [`artist_albums`](crate::clients::BaseClient). This
294    /// means that a request will be performed every `pagination_chunks` items.
295    /// By default this is [`DEFAULT_PAGINATION_CHUNKS`].
296    ///
297    /// Note that most endpoints set a maximum to the number of items per
298    /// request, which most times is 50.
299    pub pagination_chunks: u32,
300
301    /// Whether or not to save the authentication token into a JSON file,
302    /// then reread the token from JSON file when launching the program without
303    /// following the full auth process again
304    pub token_cached: bool,
305
306    /// Whether or not to check if the token has expired when sending a
307    /// request with credentials, and in that case, automatically refresh it.
308    pub token_refreshing: bool,
309
310    /// Whenever client succeeds to request or refresh a token, the callback function
311    /// will be invoked
312    pub token_callback_fn: Arc<Option<TokenCallback>>,
313}
314
315impl Default for Config {
316    fn default() -> Self {
317        Self {
318            api_base_url: String::from(DEFAULT_API_BASE_URL),
319            auth_base_url: String::from(DEFAULT_AUTH_BASE_URL),
320            cache_path: PathBuf::from(DEFAULT_CACHE_PATH),
321            pagination_chunks: DEFAULT_PAGINATION_CHUNKS,
322            token_cached: false,
323            token_refreshing: true,
324            token_callback_fn: Arc::new(None),
325        }
326    }
327}
328
329/// Generate `length` random chars from the Operating System.
330///
331/// It is assumed that system always provides high-quality cryptographically
332/// secure random data, ideally backed by hardware entropy sources.
333pub(crate) fn generate_random_string(length: usize, alphabet: &[u8]) -> String {
334    let mut buf = vec![0u8; length];
335    getrandom(&mut buf).unwrap();
336    let range = alphabet.len();
337
338    buf.iter()
339        .map(|byte| alphabet[*byte as usize % range] as char)
340        .collect()
341}
342
343#[inline]
344pub(crate) fn join_ids<'a, T: Id + 'a>(ids: impl IntoIterator<Item = T>) -> String {
345    let ids = ids.into_iter().collect::<Vec<_>>();
346    ids.iter().map(Id::id).collect::<Vec<_>>().join(",")
347}
348
349#[inline]
350pub(crate) fn join_scopes(scopes: &HashSet<String>) -> String {
351    scopes
352        .iter()
353        .map(String::as_str)
354        .collect::<Vec<_>>()
355        .join(" ")
356}
357
358/// Simple client credentials object for Spotify.
359#[derive(Debug, Clone, Default)]
360pub struct Credentials {
361    pub id: String,
362    /// PKCE doesn't require a client secret
363    pub secret: Option<String>,
364}
365
366impl Credentials {
367    /// Initialization with both the client ID and the client secret
368    #[must_use]
369    pub fn new(id: &str, secret: &str) -> Self {
370        Self {
371            id: id.to_owned(),
372            secret: Some(secret.to_owned()),
373        }
374    }
375
376    /// Initialization with just the client ID
377    #[must_use]
378    pub fn new_pkce(id: &str) -> Self {
379        Self {
380            id: id.to_owned(),
381            secret: None,
382        }
383    }
384
385    /// Parses the credentials from the environment variables
386    /// `RSPOTIFY_CLIENT_ID` and `RSPOTIFY_CLIENT_SECRET`. You can optionally
387    /// activate the `env-file` feature in order to read these variables from
388    /// a `.env` file.
389    #[must_use]
390    pub fn from_env() -> Option<Self> {
391        #[cfg(feature = "env-file")]
392        {
393            dotenvy::dotenv().ok();
394        }
395
396        Some(Self {
397            id: env::var("RSPOTIFY_CLIENT_ID").ok()?,
398            secret: env::var("RSPOTIFY_CLIENT_SECRET").ok(),
399        })
400    }
401
402    /// Generates an HTTP basic authorization header with proper formatting
403    ///
404    /// This will only work when the client secret is set to `Option::Some`.
405    #[must_use]
406    pub fn auth_headers(&self) -> Option<HashMap<String, String>> {
407        let auth = "authorization".to_owned();
408        let value = format!("{}:{}", self.id, self.secret.as_ref()?);
409        let value = format!("Basic {}", general_purpose::STANDARD.encode(value));
410
411        let mut headers = HashMap::new();
412        headers.insert(auth, value);
413        Some(headers)
414    }
415}
416
417/// Structure that holds the required information for requests with OAuth.
418#[derive(Debug, Clone)]
419pub struct OAuth {
420    pub redirect_uri: String,
421    /// The state is generated by default, as suggested by the OAuth2 spec:
422    /// [Cross-Site Request Forgery](https://tools.ietf.org/html/rfc6749#section-10.12)
423    pub state: String,
424    /// You could use macro [scopes!](crate::scopes) to build it at compile time easily
425    pub scopes: HashSet<String>,
426    pub proxies: Option<String>,
427}
428
429impl Default for OAuth {
430    fn default() -> Self {
431        Self {
432            redirect_uri: String::new(),
433            state: generate_random_string(16, alphabets::ALPHANUM),
434            scopes: HashSet::new(),
435            proxies: None,
436        }
437    }
438}
439
440impl OAuth {
441    /// Parses the credentials from the environment variable
442    /// `RSPOTIFY_REDIRECT_URI`. You can optionally activate the `env-file`
443    /// feature in order to read these variables from a `.env` file.
444    #[must_use]
445    pub fn from_env(scopes: HashSet<String>) -> Option<Self> {
446        #[cfg(feature = "env-file")]
447        {
448            dotenvy::dotenv().ok();
449        }
450
451        Some(Self {
452            scopes,
453            redirect_uri: env::var("RSPOTIFY_REDIRECT_URI").ok()?,
454            ..Default::default()
455        })
456    }
457}
458
459#[cfg(test)]
460pub mod test {
461    use crate::{alphabets, generate_random_string, Credentials};
462    use std::collections::HashSet;
463    use wasm_bindgen_test::*;
464
465    #[test]
466    #[wasm_bindgen_test]
467    fn test_generate_random_string() {
468        let mut containers = HashSet::new();
469        for _ in 1..101 {
470            containers.insert(generate_random_string(10, alphabets::ALPHANUM));
471        }
472        assert_eq!(containers.len(), 100);
473    }
474
475    #[test]
476    #[wasm_bindgen_test]
477    fn test_basic_auth() {
478        let creds = Credentials::new_pkce("ramsay");
479        let headers = creds.auth_headers();
480        assert_eq!(headers, None);
481
482        let creds = Credentials::new("ramsay", "123456");
483
484        let headers = creds.auth_headers().unwrap();
485        assert_eq!(headers.len(), 1);
486        assert_eq!(
487            headers.get("authorization"),
488            Some(&"Basic cmFtc2F5OjEyMzQ1Ng==".to_owned())
489        );
490    }
491}