use chrono::{DateTime, Utc};
use dotenv::dotenv;
use rand::{self, Rng};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use snafu::ResultExt;
use strum_macros::{Display, EnumString};
use url::Url;
use std::collections::HashMap;
use std::env;
use std::str::FromStr;
use std::string::ToString;
mod error;
use crate::error::{SerdeError, *};
const SPOTIFY_AUTH_URL: &str = "https://accounts.spotify.com/authorize";
const SPOTIFY_TOKEN_URL: &str = "https://accounts.spotify.com/api/token";
pub fn datetime_to_timestamp(elapsed: u32) -> i64 {
let utc: DateTime<Utc> = Utc::now();
utc.timestamp() + i64::from(elapsed)
}
pub fn generate_random_string(length: usize) -> String {
rand::thread_rng()
.sample_iter(&rand::distributions::Alphanumeric)
.take(length)
.collect()
}
#[derive(EnumString, Serialize, Deserialize, Display, Debug, Clone, PartialEq)]
pub enum SpotifyScope {
#[strum(serialize = "user-read-recently-played")]
UserReadRecentlyPlayed,
#[strum(serialize = "user-top-read")]
UserTopRead,
#[strum(serialize = "user-library-modify")]
UserLibraryModify,
#[strum(serialize = "user-library-read")]
UserLibraryRead,
#[strum(serialize = "playlist-read-private")]
PlaylistReadPrivate,
#[strum(serialize = "playlist-modify-public")]
PlaylistModifyPublic,
#[strum(serialize = "playlist-modify-private")]
PlaylistModifyPrivate,
#[strum(serialize = "playlist-read-collaborative")]
PlaylistReadCollaborative,
#[strum(serialize = "user-read-email")]
UserReadEmail,
#[strum(serialize = "user-read-birthdate")]
UserReadBirthDate,
#[strum(serialize = "user-read-private")]
UserReadPrivate,
#[strum(serialize = "user-read-playback-state")]
UserReadPlaybackState,
#[strum(serialize = "user-modify-playback-state")]
UserModifyPlaybackState,
#[strum(serialize = "user-read-currently-playing")]
UserReadCurrentlyPlaying,
#[strum(serialize = "app-remote-control")]
AppRemoteControl,
#[strum(serialize = "streaming")]
Streaming,
#[strum(serialize = "user-follow-read")]
UserFollowRead,
#[strum(serialize = "user-follow-modify")]
UserFollowModify,
}
pub struct SpotifyAuth {
pub client_id: String,
pub client_secret: String,
pub response_type: String,
pub redirect_uri: Url,
pub state: String,
pub scope: Vec<SpotifyScope>,
pub show_dialog: bool,
}
impl Default for SpotifyAuth {
fn default() -> Self {
dotenv().ok();
Self {
client_id: env::var("SPOTIFY_CLIENT_ID").context(EnvError).unwrap(),
client_secret: env::var("SPOTIFY_CLIENT_SECRET").context(EnvError).unwrap(),
response_type: "code".to_owned(),
redirect_uri: Url::parse(&env::var("REDIRECT_URI").context(EnvError).unwrap())
.context(UrlError)
.unwrap(),
state: generate_random_string(20),
scope: vec![],
show_dialog: false,
}
}
}
impl SpotifyAuth {
pub fn new(
client_id: String,
client_secret: String,
response_type: String,
redirect_uri: String,
scope: Vec<SpotifyScope>,
show_dialog: bool,
) -> Self {
Self {
client_id,
client_secret,
response_type,
redirect_uri: Url::parse(&redirect_uri).context(UrlError).unwrap(),
state: generate_random_string(20),
scope,
show_dialog,
}
}
pub fn new_from_env(
response_type: String,
scope: Vec<SpotifyScope>,
show_dialog: bool,
) -> Self {
dotenv().ok();
Self {
client_id: env::var("SPOTIFY_CLIENT_ID").context(EnvError).unwrap(),
client_secret: env::var("SPOTIFY_CLIENT_SECRET").context(EnvError).unwrap(),
response_type,
redirect_uri: Url::parse(&env::var("SPOTIFY_REDIRECT_URI").context(EnvError).unwrap())
.context(UrlError)
.unwrap(),
state: generate_random_string(20),
scope,
show_dialog,
}
}
pub fn scope_into_string(&self) -> String {
self.scope
.iter()
.map(|x| x.clone().to_string())
.collect::<Vec<String>>()
.join(" ")
}
pub fn authorize_url(&self) -> SpotifyResult<String> {
let mut url = Url::parse(SPOTIFY_AUTH_URL).context(UrlError)?;
url.query_pairs_mut()
.append_pair("client_id", &self.client_id)
.append_pair("response_type", &self.response_type)
.append_pair("redirect_uri", self.redirect_uri.as_str())
.append_pair("state", &self.state)
.append_pair("scope", &self.scope_into_string())
.append_pair("show_dialog", &self.show_dialog.to_string());
Ok(url.to_string())
}
}
#[derive(Debug, PartialEq)]
pub struct SpotifyCallback {
code: Option<String>,
error: Option<String>,
state: String,
}
impl FromStr for SpotifyCallback {
type Err = error::SpotifyError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let url = Url::parse(s).context(UrlError)?;
let parsed: Vec<(String, String)> = url
.query_pairs()
.map(|x| (x.0.into_owned(), x.1.into_owned()))
.collect();
let has_state = parsed.iter().any(|x| x.0 == "state");
let has_response = parsed.iter().any(|x| x.0 == "error" || x.0 == "code");
if !has_state && !has_response {
return Err(SpotifyError::CallbackFailure {
context: "Does not contain any state or response type query parameters.",
});
} else if !has_state {
return Err(SpotifyError::CallbackFailure {
context: "Does not contain any state type query parameters.",
});
} else if !has_response {
return Err(SpotifyError::CallbackFailure {
context: "Does not contain any response type query parameters.",
});
}
let state = match parsed.iter().find(|x| x.0 == "state") {
None => ("state".to_string(), "".to_string()),
Some(x) => x.clone(),
};
let response = match parsed.iter().find(|x| x.0 == "error" || x.0 == "code") {
None => ("error".to_string(), "access_denied".to_string()),
Some(x) => x.clone(),
};
if response.0 == "code" {
return Ok(Self {
code: Some(response.to_owned().1),
error: None,
state: state.1,
});
} else if response.0 == "error" {
return Ok(Self {
code: None,
error: Some(response.to_owned().1),
state: state.1,
});
}
Err(SpotifyError::CallbackFailure {
context: "Does not contain any state or response type query parameters.",
})
}
}
impl SpotifyCallback {
pub fn new(code: Option<String>, error: Option<String>, state: String) -> Self {
Self { code, error, state }
}
pub async fn convert_into_token(
self,
client_id: String,
client_secret: String,
redirect_uri: Url,
) -> SpotifyResult<SpotifyToken> {
let mut payload: HashMap<String, String> = HashMap::new();
payload.insert("grant_type".to_owned(), "authorization_code".to_owned());
payload.insert(
"code".to_owned(),
match self.code {
None => {
return Err(SpotifyError::TokenFailure {
context: "Spotify callback code failed to parse.",
})
}
Some(x) => x,
},
);
payload.insert("redirect_uri".to_owned(), redirect_uri.to_string());
let auth_value = base64::encode(&format!("{}:{}", client_id, client_secret));
let mut response = surf::post(SPOTIFY_TOKEN_URL)
.set_header("Authorization", format!("Basic {}", auth_value))
.body_form(&payload)
.unwrap()
.await
.context(SurfError)?;
let buf = response.body_string().await.unwrap();
if response.status().is_success() {
let mut token: SpotifyToken = serde_json::from_str(&buf).context(SerdeError)?;
token.expires_at = Some(datetime_to_timestamp(token.expires_in));
return Ok(token);
}
Err(SpotifyError::TokenFailure {
context: "Failed to convert callback into token",
})
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct SpotifyToken {
pub access_token: String,
pub token_type: String,
#[serde(deserialize_with = "deserialize_scope_field")]
pub scope: Vec<SpotifyScope>,
pub expires_in: u32,
pub expires_at: Option<i64>,
pub refresh_token: String,
}
fn deserialize_scope_field<'de, D>(de: D) -> Result<Vec<SpotifyScope>, D::Error>
where
D: Deserializer<'de>,
{
let result: Value = Deserialize::deserialize(de)?;
match result {
Value::String(ref s) => {
let split: Vec<&str> = s.split_whitespace().collect();
let mut parsed: Vec<SpotifyScope> = Vec::new();
for x in split {
parsed.push(SpotifyScope::from_str(x).unwrap());
}
Ok(parsed)
}
_ => Ok(vec![]),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_callback_code() {
let url = String::from("http://localhost:8888/callback?code=AQD0yXvFEOvw&state=sN");
assert_eq!(
SpotifyCallback::from_str(&url).unwrap(),
SpotifyCallback::new(Some("AQD0yXvFEOvw".to_string()), None, "sN".to_string())
);
}
#[test]
fn test_parse_callback_error() {
let url = String::from("http://localhost:8888/callback?error=access_denied&state=sN");
assert_eq!(
SpotifyCallback::from_str(&url).unwrap(),
SpotifyCallback::new(None, Some("access_denied".to_string()), "sN".to_string())
);
}
#[test]
fn test_invalid_response_parse() {
let url = String::from("http://localhost:8888/callback?state=sN");
assert_eq!(
SpotifyCallback::from_str(&url).unwrap_err().to_string(),
"Callback URL parsing failure: Does not contain any response type query parameters."
);
}
#[test]
fn test_invalid_parse() {
let url = String::from("http://localhost:8888/callback");
assert_eq!(
SpotifyCallback::from_str(&url).unwrap_err().to_string(),
"Callback URL parsing failure: Does not contain any state or response type query parameters."
);
}
#[test]
fn test_token_parse() {
let token_json = r#"{
"access_token": "NgCXRKDjGUSKlfJODUjvnSUhcOMzYjw",
"token_type": "Bearer",
"scope": "user-read-private user-read-email",
"expires_in": 3600,
"refresh_token": "NgAagAHfVxDkSvCUm_SHo"
}"#;
let mut token: SpotifyToken = serde_json::from_str(token_json).unwrap();
let timestamp = datetime_to_timestamp(token.expires_in);
token.expires_at = Some(timestamp);
assert_eq!(
SpotifyToken {
access_token: "NgCXRKDjGUSKlfJODUjvnSUhcOMzYjw".to_string(),
token_type: "Bearer".to_string(),
scope: vec![SpotifyScope::UserReadPrivate, SpotifyScope::UserReadEmail],
expires_in: 3600,
expires_at: Some(timestamp),
refresh_token: "NgAagAHfVxDkSvCUm_SHo".to_string()
},
token
);
}
}