summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--Cargo.toml10
-rw-r--r--examples/access_token/main.rs52
-rw-r--r--src/auth/mod.rs145
-rw-r--r--src/auth/service.rs40
-rw-r--r--src/client.rs50
-rw-r--r--src/errors/auth.rs23
-rw-r--r--src/errors/mod.rs3
-rw-r--r--src/lib.rs3
9 files changed, 327 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
index 4fffb2f..a308a06 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
/target
/Cargo.lock
+Settings.toml
diff --git a/Cargo.toml b/Cargo.toml
index 7405a2d..7493fcb 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,3 +3,13 @@ name = "deez"
version = "0.1.0"
edition = "2021"
+[dependencies]
+actix-web = { version = "4.1.0", default-features = false, features = ["macros"] }
+tokio = { version = "1.21.0", features = ["macros", "rt-multi-thread"] }
+serde = { version = "1.0.144", features = ["derive"] }
+reqwest = { version = "0.11.11", features = ["json"] }
+thiserror = "1.0.33"
+
+[dev_dependencies]
+config = "0.13.2"
+anyhow = "1.0.63"
diff --git a/examples/access_token/main.rs b/examples/access_token/main.rs
new file mode 100644
index 0000000..d9b4d79
--- /dev/null
+++ b/examples/access_token/main.rs
@@ -0,0 +1,52 @@
+use std::error::Error;
+
+use anyhow::Context;
+use config::Config;
+use deez::auth::{request_access_token, AuthPromptHandler};
+use serde::Deserialize;
+
+#[derive(Deserialize)]
+struct Settings
+{
+ app_id: u32,
+ secret_key: String,
+ address: String,
+ port: u16,
+ uri_scheme: String,
+}
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn Error + Send + Sync>>
+{
+ let settings = Config::builder()
+ .add_source(config::File::with_name("examples/access_token/Settings"))
+ .build()
+ .with_context(|| "Failed to read settings")?
+ .try_deserialize::<Settings>()
+ .with_context(|| "Invalid settings")?;
+
+ let auth_code_prompt_info = AuthPromptHandler::run(
+ settings.app_id,
+ settings.address,
+ settings.port,
+ settings.uri_scheme,
+ )
+ .await?;
+
+ println!("{}", auth_code_prompt_info.auth_prompt_url);
+
+ let auth_code = auth_code_prompt_info.handler.await??;
+
+ println!("Retrieved authentication code '{}'", auth_code);
+
+ let access_token =
+ request_access_token(settings.app_id, settings.secret_key, auth_code).await?;
+
+ println!(
+ "Retrieved access token '{}' which expires in {} seconds",
+ access_token.access_token,
+ access_token.expires.as_secs()
+ );
+
+ Ok(())
+}
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;
diff --git a/src/lib.rs b/src/lib.rs
index 870d3f7..1f2576f 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -3,3 +3,6 @@
#![deny(clippy::pedantic)]
#![deny(missing_docs)]
#![allow(clippy::module_name_repetitions)]
+
+pub mod auth;
+pub mod errors;