diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/auth/mod.rs | 145 | ||||
-rw-r--r-- | src/auth/service.rs | 40 | ||||
-rw-r--r-- | src/client.rs | 50 | ||||
-rw-r--r-- | src/errors/auth.rs | 23 | ||||
-rw-r--r-- | src/errors/mod.rs | 3 | ||||
-rw-r--r-- | src/lib.rs | 3 |
6 files changed, 264 insertions, 0 deletions
diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 0000000..c5beeb3 --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,145 @@ +//! Deezer API authentication. +use std::error::Error; +use std::fmt::Display; +use std::time::Duration; + +use actix_web::web::Data; +use actix_web::{App, HttpServer}; +use serde::Deserialize; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; +use tokio::{select, spawn}; + +use crate::auth::service::retrieve_auth_code; +use crate::errors::auth::{AccessTokenRequestError, AuthPromptHandlerError}; + +mod service; + +const AUTH_URL: &str = "https://connect.deezer.com/oauth/auth.php"; +const ACCESS_TOKEN_URL: &str = "https://connect.deezer.com/oauth/access_token.php"; + +/// A Deezer authentication code. +#[derive(Debug, Deserialize, Clone)] +pub struct AuthCode(String); + +impl Display for AuthCode +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result + { + self.0.fmt(f) + } +} + +/// A Deezer authentication prompt handler. +pub struct AuthPromptHandler +{ + /// URL to the Deezer authentication prompt. + pub auth_prompt_url: String, + + /// Handle for the running authentication prompt handler. + /// + /// Finishes when a single authentication has occurred. + pub handler: JoinHandle<Result<AuthCode, Box<dyn Error + Send + Sync>>>, +} + +impl AuthPromptHandler +{ + /// Runs a web server that handles a Deezer authentication prompt. + /// + /// The argument `address` must be in the same domain as the domain you defined when + /// you created the application with the ID `app_id`. + /// + /// # Errors + /// Will return Err if the web server created is unable to bind to `address`. + pub async fn run( + app_id: u32, + address: String, + port: u16, + uri_scheme: String, + ) -> Result<Self, AuthPromptHandlerError> + { + let (done_tx, mut done_rx) = mpsc::channel::<AuthCode>(1); + + let done_tx_data = Data::new(done_tx); + + let server = HttpServer::new(move || { + App::new() + .app_data(done_tx_data.clone()) + .service(retrieve_auth_code) + }) + .bind((address.clone(), port)) + .map_err(|_| AuthPromptHandlerError::BindAddressFailed)?; + + let server_future = server.run(); + + let handle = spawn(async move { + let opt_auth_code = select! { + result = server_future => { + result.map(|_| None) + }, + auth_code = async { + done_rx.recv().await + } => Ok(auth_code) + }?; + + Ok(opt_auth_code.map_or_else(|| Err("No auth code was received"), Ok)?) + }); + + Ok(Self { + auth_prompt_url: format!( + "{}?app_id={}&redirect_uri={}://{}:{}&perms=basic_access", + AUTH_URL, app_id, uri_scheme, address, port + ), + handler: handle, + }) + } +} + +/// A Deezer access token. +pub struct AccessToken +{ + /// The access token. + pub access_token: String, + + /// The duration until the access token expires. + pub expires: Duration, +} + +/// Sends a request for a access token. +/// +/// # Errors +/// Will return Err if either sending the request fails or parsing the response fails. +pub async fn request_access_token( + app_id: u32, + secret_key: String, + auth_code: AuthCode, +) -> Result<AccessToken, AccessTokenRequestError> +{ + let response = reqwest::get(format!( + "{}?app_id={}&secret={}&code={}&output=json", + ACCESS_TOKEN_URL, app_id, secret_key, auth_code + )) + .await? + .json::<AccessTokenResponse>() + .await?; + + Ok(response.into()) +} + +#[derive(Debug, Deserialize)] +struct AccessTokenResponse +{ + pub access_token: String, + pub expires: u64, +} + +impl From<AccessTokenResponse> for AccessToken +{ + fn from(response: AccessTokenResponse) -> Self + { + Self { + access_token: response.access_token, + expires: Duration::from_secs(response.expires), + } + } +} diff --git a/src/auth/service.rs b/src/auth/service.rs new file mode 100644 index 0000000..b9b44d4 --- /dev/null +++ b/src/auth/service.rs @@ -0,0 +1,40 @@ +use actix_web::web::{Data, Query}; +use actix_web::{get, HttpResponse}; +use serde::Deserialize; +use tokio::sync::mpsc; + +use crate::auth::AuthCode; + +#[derive(Debug, Deserialize)] +struct AuthCodeQuery +{ + error_reason: Option<String>, + code: Option<AuthCode>, +} + +#[get("/")] +async fn retrieve_auth_code( + query: Query<AuthCodeQuery>, + done_tx: Data<mpsc::Sender<AuthCode>>, +) -> HttpResponse +{ + if let Some(error_reason) = &query.error_reason { + return HttpResponse::Unauthorized().body(format!( + "Error: No authentication code was retrieved. Reason: {}\n\nYou can close this tab", + error_reason + )); + } + + let auth_code = match &query.code { + Some(auth_code) => auth_code, + None => { + return HttpResponse::BadRequest() + .body("Error: No authentication code was retrieved"); + } + }; + + done_tx.send(auth_code.clone()).await.unwrap(); + + HttpResponse::Ok() + .body("Authentication code was successfully retrieved.\n\nYou can close this tab") +} diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..5c6858b --- /dev/null +++ b/src/client.rs @@ -0,0 +1,50 @@ +//! Deezer client. +use std::error::Error; + +use serde::Deserialize; + +/// A user. +#[derive(Debug, Deserialize)] +pub struct User +{ + /// The user ID. + pub id: u32, + + /// The user name. + pub name: String, +} + +/// Deezer client. +#[derive(Default)] +pub struct DeezerClient +{ + client: reqwest::Client, + api_url: &'static str, +} + +impl DeezerClient +{ + /// Creates a new Deezer client. + #[must_use] + pub fn new() -> Self + { + Self { + client: reqwest::Client::new(), + api_url: "https://api.deezer.com", + } + } + + /// Returns the authenticated user. + pub async fn get_me(&self) -> Result<User, Box<dyn Error + Send + Sync>> + { + let user = self + .client + .get(format!("{}/user/me", self.api_url)) + .send() + .await? + .json::<User>() + .await?; + + Ok(user) + } +} diff --git a/src/errors/auth.rs b/src/errors/auth.rs new file mode 100644 index 0000000..cd4741d --- /dev/null +++ b/src/errors/auth.rs @@ -0,0 +1,23 @@ +//! Authentication related error types. + +/// Authentication prompt handler error. +#[derive(Debug, thiserror::Error)] +pub enum AuthPromptHandlerError +{ + /// HTTP server failed to bind to a address. + #[error("HTTP server failed to bind to address")] + BindAddressFailed, +} + +/// Access token request error. +#[derive(Debug, thiserror::Error)] +pub enum AccessTokenRequestError +{ + /// Sending access token request failed. + #[error("Sending access token request failed")] + SendFailed(#[from] reqwest::Error), + + /// Parsing access token respone failed. + #[error("Parsing access token respone failed")] + ResponseParseFailed, +} diff --git a/src/errors/mod.rs b/src/errors/mod.rs new file mode 100644 index 0000000..ea85f4a --- /dev/null +++ b/src/errors/mod.rs @@ -0,0 +1,3 @@ +//! Error types. + +pub mod auth; @@ -3,3 +3,6 @@ #![deny(clippy::pedantic)] #![deny(missing_docs)] #![allow(clippy::module_name_repetitions)] + +pub mod auth; +pub mod errors; |