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}