mod auth_code;
mod auth_code_pkce;
mod client_creds;
pub mod clients;
pub mod sync;
mod util;
pub use rspotify_http as http;
pub use rspotify_macros as macros;
pub use rspotify_model as model;
pub use auth_code::AuthCodeSpotify;
pub use auth_code_pkce::AuthCodePkceSpotify;
pub use client_creds::ClientCredsSpotify;
pub use macros::scopes;
pub use model::Token;
use crate::{http::HttpError, model::Id};
use std::{
collections::{HashMap, HashSet},
env, fmt,
path::PathBuf,
sync::Arc,
};
use base64::{engine::general_purpose, Engine as _};
use getrandom::getrandom;
use thiserror::Error;
pub mod prelude {
pub use crate::clients::{BaseClient, OAuthClient};
pub use crate::model::idtypes::{Id, PlayContextId, PlayableId};
}
pub(crate) mod params {
pub const CLIENT_ID: &str = "client_id";
pub const CODE: &str = "code";
pub const GRANT_TYPE: &str = "grant_type";
pub const GRANT_TYPE_AUTH_CODE: &str = "authorization_code";
pub const GRANT_TYPE_CLIENT_CREDS: &str = "client_credentials";
pub const GRANT_TYPE_REFRESH_TOKEN: &str = "refresh_token";
pub const REDIRECT_URI: &str = "redirect_uri";
pub const REFRESH_TOKEN: &str = "refresh_token";
pub const RESPONSE_TYPE_CODE: &str = "code";
pub const RESPONSE_TYPE: &str = "response_type";
pub const SCOPE: &str = "scope";
pub const SHOW_DIALOG: &str = "show_dialog";
pub const STATE: &str = "state";
pub const CODE_CHALLENGE: &str = "code_challenge";
pub const CODE_VERIFIER: &str = "code_verifier";
pub const CODE_CHALLENGE_METHOD: &str = "code_challenge_method";
pub const CODE_CHALLENGE_METHOD_S256: &str = "S256";
}
pub(crate) mod alphabets {
pub const ALPHANUM: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
pub const PKCE_CODE_VERIFIER: &[u8] =
b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~";
}
pub(crate) mod auth_urls {
pub const AUTHORIZE: &str = "authorize";
pub const TOKEN: &str = "api/token";
}
#[derive(Debug, Error)]
pub enum ClientError {
#[error("json parse error: {0}")]
ParseJson(#[from] serde_json::Error),
#[error("url parse error: {0}")]
ParseUrl(#[from] url::ParseError),
#[error("http error: {0}")]
Http(Box<HttpError>),
#[error("input/output error: {0}")]
Io(#[from] std::io::Error),
#[cfg(feature = "cli")]
#[error("cli error: {0}")]
Cli(String),
#[error("cache file error: {0}")]
CacheFile(String),
#[error("token callback function error: {0}")]
TokenCallbackFn(#[from] CallbackError),
#[error("model error: {0}")]
Model(#[from] model::ModelError),
#[error("Token is not valid")]
InvalidToken,
}
impl From<HttpError> for ClientError {
fn from(err: HttpError) -> Self {
Self::Http(Box::new(err))
}
}
pub type ClientResult<T> = Result<T, ClientError>;
pub const DEFAULT_API_BASE_URL: &str = "https://api.spotify.com/v1/";
pub const DEFAULT_AUTH_BASE_URL: &str = "https://accounts.spotify.com/";
pub const DEFAULT_CACHE_PATH: &str = ".spotify_token_cache.json";
pub const DEFAULT_PAGINATION_CHUNKS: u32 = 50;
#[derive(Error, Debug)]
pub enum CallbackError {
#[error("The callback function raises an error: `{0}`")]
CustomizedError(String),
}
pub struct TokenCallback(pub Box<dyn Fn(Token) -> Result<(), CallbackError> + Send + Sync>);
impl fmt::Debug for TokenCallback {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("TokenCallback")
}
}
#[derive(Debug, Clone)]
pub struct Config {
pub api_base_url: String,
pub auth_base_url: String,
pub cache_path: PathBuf,
pub pagination_chunks: u32,
pub token_cached: bool,
pub token_refreshing: bool,
pub token_callback_fn: Arc<Option<TokenCallback>>,
}
impl Default for Config {
fn default() -> Self {
Self {
api_base_url: String::from(DEFAULT_API_BASE_URL),
auth_base_url: String::from(DEFAULT_AUTH_BASE_URL),
cache_path: PathBuf::from(DEFAULT_CACHE_PATH),
pagination_chunks: DEFAULT_PAGINATION_CHUNKS,
token_cached: false,
token_refreshing: true,
token_callback_fn: Arc::new(None),
}
}
}
pub(crate) fn generate_random_string(length: usize, alphabet: &[u8]) -> String {
let mut buf = vec![0u8; length];
getrandom(&mut buf).unwrap();
let range = alphabet.len();
buf.iter()
.map(|byte| alphabet[*byte as usize % range] as char)
.collect()
}
#[inline]
pub(crate) fn join_ids<'a, T: Id + 'a>(ids: impl IntoIterator<Item = T>) -> String {
let ids = ids.into_iter().collect::<Vec<_>>();
ids.iter().map(Id::id).collect::<Vec<_>>().join(",")
}
#[inline]
pub(crate) fn join_scopes(scopes: &HashSet<String>) -> String {
scopes
.iter()
.map(String::as_str)
.collect::<Vec<_>>()
.join(" ")
}
#[derive(Debug, Clone, Default)]
pub struct Credentials {
pub id: String,
pub secret: Option<String>,
}
impl Credentials {
#[must_use]
pub fn new(id: &str, secret: &str) -> Self {
Self {
id: id.to_owned(),
secret: Some(secret.to_owned()),
}
}
#[must_use]
pub fn new_pkce(id: &str) -> Self {
Self {
id: id.to_owned(),
secret: None,
}
}
#[must_use]
pub fn from_env() -> Option<Self> {
#[cfg(feature = "env-file")]
{
dotenvy::dotenv().ok();
}
Some(Self {
id: env::var("RSPOTIFY_CLIENT_ID").ok()?,
secret: env::var("RSPOTIFY_CLIENT_SECRET").ok(),
})
}
#[must_use]
pub fn auth_headers(&self) -> Option<HashMap<String, String>> {
let auth = "authorization".to_owned();
let value = format!("{}:{}", self.id, self.secret.as_ref()?);
let value = format!("Basic {}", general_purpose::STANDARD.encode(value));
let mut headers = HashMap::new();
headers.insert(auth, value);
Some(headers)
}
}
#[derive(Debug, Clone)]
pub struct OAuth {
pub redirect_uri: String,
pub state: String,
pub scopes: HashSet<String>,
pub proxies: Option<String>,
}
impl Default for OAuth {
fn default() -> Self {
Self {
redirect_uri: String::new(),
state: generate_random_string(16, alphabets::ALPHANUM),
scopes: HashSet::new(),
proxies: None,
}
}
}
impl OAuth {
#[must_use]
pub fn from_env(scopes: HashSet<String>) -> Option<Self> {
#[cfg(feature = "env-file")]
{
dotenvy::dotenv().ok();
}
Some(Self {
scopes,
redirect_uri: env::var("RSPOTIFY_REDIRECT_URI").ok()?,
..Default::default()
})
}
}
#[cfg(test)]
pub mod test {
use crate::{alphabets, generate_random_string, Credentials};
use std::collections::HashSet;
use wasm_bindgen_test::*;
#[test]
#[wasm_bindgen_test]
fn test_generate_random_string() {
let mut containers = HashSet::new();
for _ in 1..101 {
containers.insert(generate_random_string(10, alphabets::ALPHANUM));
}
assert_eq!(containers.len(), 100);
}
#[test]
#[wasm_bindgen_test]
fn test_basic_auth() {
let creds = Credentials::new_pkce("ramsay");
let headers = creds.auth_headers();
assert_eq!(headers, None);
let creds = Credentials::new("ramsay", "123456");
let headers = creds.auth_headers().unwrap();
assert_eq!(headers.len(), 1);
assert_eq!(
headers.get("authorization"),
Some(&"Basic cmFtc2F5OjEyMzQ1Ng==".to_owned())
);
}
}