-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(auth): first implementation of auth login command
- Loading branch information
1 parent
832acd1
commit 4c17d5e
Showing
9 changed files
with
433 additions
and
44 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,166 @@ | ||
use std::time::Duration; | ||
|
||
use crate::{config::Config, logger::get_local_logger, prelude::*}; | ||
use clap::{Args, Subcommand}; | ||
use gql_client::{Client as GQLClient, ClientConfig}; | ||
use nestify::nest; | ||
use serde::{Deserialize, Serialize}; | ||
use simplelog::CombinedLogger; | ||
use tokio::time::{sleep, Instant}; | ||
|
||
#[derive(Debug, Args)] | ||
pub struct AuthArgs { | ||
/// The URL of the CodSpeed GraphQL API | ||
#[arg(long, env = "CODSPEED_API_URL", global = true, hide = true)] | ||
api_url: Option<String>, | ||
|
||
#[command(subcommand)] | ||
command: AuthCommands, | ||
} | ||
|
||
#[derive(Debug, Subcommand)] | ||
enum AuthCommands { | ||
/// Login to CodSpeed | ||
Login, | ||
} | ||
|
||
// TODO: tweak the logger to make it more user-friendly | ||
fn init_logger() -> Result<()> { | ||
let logger = get_local_logger(); | ||
CombinedLogger::init(vec![logger])?; | ||
Ok(()) | ||
} | ||
|
||
pub async fn run(args: AuthArgs) -> Result<()> { | ||
init_logger()?; | ||
let api_client = CodSpeedAPIClient::from(&args); | ||
|
||
match args.command { | ||
AuthCommands::Login => login(api_client).await?, | ||
} | ||
Ok(()) | ||
} | ||
|
||
nest! { | ||
#[derive(Debug, Deserialize, Serialize)]* | ||
#[serde(rename_all = "camelCase")]* | ||
struct CreateLoginSessionData { | ||
create_login_session: struct CreateLoginSessionPayload { | ||
callback_url: String, | ||
session_id: String, | ||
} | ||
} | ||
} | ||
|
||
nest! { | ||
#[derive(Debug, Deserialize, Serialize)]* | ||
#[serde(rename_all = "camelCase")]* | ||
struct ConsumeLoginSessionData { | ||
consume_login_session: struct ConsumeLoginSessionPayload { | ||
token: Option<String> | ||
} | ||
} | ||
} | ||
|
||
#[derive(Serialize)] | ||
#[serde(rename_all = "camelCase")] | ||
struct ConsumeLoginSessionVars { | ||
session_id: String, | ||
} | ||
|
||
struct CodSpeedAPIClient { | ||
gql_client: GQLClient, | ||
} | ||
|
||
impl From<&AuthArgs> for CodSpeedAPIClient { | ||
fn from(args: &AuthArgs) -> Self { | ||
Self { | ||
gql_client: build_gql_api_client(args.api_url.clone()), | ||
} | ||
} | ||
} | ||
|
||
const CODSPEED_GRAPHQL_ENDPOINT: &str = "https://gql.codspeed.io/"; | ||
|
||
fn build_gql_api_client(api_url: Option<String>) -> GQLClient { | ||
let endpoint = api_url.unwrap_or_else(|| CODSPEED_GRAPHQL_ENDPOINT.to_string()); | ||
|
||
GQLClient::new_with_config(ClientConfig { | ||
endpoint, | ||
timeout: Some(10), | ||
headers: Default::default(), | ||
proxy: None, | ||
}) | ||
} | ||
|
||
impl CodSpeedAPIClient { | ||
async fn create_login_session(&self) -> Result<CreateLoginSessionPayload> { | ||
let response = self | ||
.gql_client | ||
.query_unwrap::<CreateLoginSessionData>(include_str!("queries/CreateLoginSession.gql")) | ||
.await; | ||
match response { | ||
Ok(response) => Ok(response.create_login_session), | ||
Err(err) => bail!("Failed to create login session: {}", err), | ||
} | ||
} | ||
|
||
async fn consume_login_session(&self, session_id: &str) -> Result<ConsumeLoginSessionPayload> { | ||
let response = self | ||
.gql_client | ||
.query_with_vars_unwrap::<ConsumeLoginSessionData, ConsumeLoginSessionVars>( | ||
include_str!("queries/ConsumeLoginSession.gql"), | ||
ConsumeLoginSessionVars { | ||
session_id: session_id.to_string(), | ||
}, | ||
) | ||
.await; | ||
match response { | ||
Ok(response) => Ok(response.consume_login_session), | ||
Err(err) => bail!("Failed to use login session: {}", err), | ||
} | ||
} | ||
} | ||
|
||
const LOGIN_SESSION_MAX_DURATION: Duration = Duration::from_secs(60 * 5); // 5 minutes | ||
|
||
async fn login(api_client: CodSpeedAPIClient) -> Result<()> { | ||
debug!("Login to CodSpeed"); | ||
debug!("Creating login session..."); | ||
let login_session_payload = api_client.create_login_session().await?; | ||
info!( | ||
"Login session created, open the following URL in your browser: {}", | ||
login_session_payload.callback_url | ||
); | ||
|
||
info!("Waiting for the login to be completed..."); | ||
let token; | ||
let start = Instant::now(); | ||
loop { | ||
if LOGIN_SESSION_MAX_DURATION < start.elapsed() { | ||
bail!("Login session expired, please try again"); | ||
} | ||
|
||
match api_client | ||
.consume_login_session(&login_session_payload.session_id) | ||
.await? | ||
.token | ||
{ | ||
Some(token_from_api) => { | ||
token = token_from_api; | ||
break; | ||
} | ||
None => sleep(Duration::from_secs(5)).await, | ||
} | ||
} | ||
debug!("Login completed"); | ||
|
||
let mut config = Config::load().await?; | ||
config.auth.token = token; | ||
config.persist().await?; | ||
debug!("Token saved to configuration file"); | ||
|
||
info!("Login successful, your are now authenticated on CodSpeed"); | ||
|
||
Ok(()) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,73 @@ | ||
use std::{env, path::PathBuf}; | ||
|
||
use crate::prelude::*; | ||
use nestify::nest; | ||
use serde::{Deserialize, Serialize}; | ||
|
||
nest! { | ||
#[derive(Debug, Deserialize, Serialize)]* | ||
#[serde(rename_all = "kebab-case")]* | ||
pub struct Config { | ||
pub auth: pub struct AuthConfig { | ||
pub token: String, | ||
} | ||
} | ||
} | ||
|
||
/// Get the path to the configuration file, following the XDG Base Directory Specification | ||
/// at https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html | ||
fn get_configuration_file_path() -> PathBuf { | ||
let config_dir = env::var("XDG_CONFIG_HOME") | ||
.map(PathBuf::from) | ||
.unwrap_or_else(|_| { | ||
let home = env::var("HOME").expect("HOME env variable not set"); | ||
PathBuf::from(home).join(".config") | ||
}); | ||
let config_dir = config_dir.join("codspeed"); | ||
config_dir.join("config.yaml") | ||
} | ||
|
||
impl Default for Config { | ||
fn default() -> Self { | ||
Self { | ||
auth: AuthConfig { token: "".into() }, | ||
} | ||
} | ||
} | ||
|
||
impl Config { | ||
/// Load the configuration. If it does not exist, store and return a default configuration | ||
pub async fn load() -> Result<Self> { | ||
let config_path = get_configuration_file_path(); | ||
|
||
match tokio::fs::read(&config_path).await { | ||
Ok(config_str) => { | ||
let config = serde_yaml::from_slice(&config_str).context(format!( | ||
"Failed to parse CodSpeed config at {}", | ||
config_path.display() | ||
))?; | ||
debug!("Config loaded from {}", config_path.display()); | ||
Ok(config) | ||
} | ||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => { | ||
debug!("Config file not found at {}", config_path.display()); | ||
let config = Config::default(); | ||
config.persist().await?; | ||
Ok(config) | ||
} | ||
Err(e) => bail!("Failed to load config: {}", e), | ||
} | ||
} | ||
|
||
/// Persist changes to the configuration | ||
pub async fn persist(&self) -> Result<()> { | ||
let config_path = get_configuration_file_path(); | ||
tokio::fs::create_dir_all(config_path.parent().unwrap()).await?; | ||
|
||
let config_str = serde_yaml::to_string(self)?; | ||
tokio::fs::write(&config_path, config_str).await?; | ||
debug!("Config written to {}", config_path.display()); | ||
|
||
Ok(()) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
use std::env; | ||
|
||
use simplelog::{ConfigBuilder, SharedLogger}; | ||
|
||
pub fn get_local_logger() -> Box<dyn SharedLogger> { | ||
let log_level = env::var("CODSPEED_LOG") | ||
.ok() | ||
.and_then(|log_level| log_level.parse::<log::LevelFilter>().ok()) | ||
.unwrap_or(log::LevelFilter::Info); | ||
|
||
let config = ConfigBuilder::new() | ||
.set_time_level(log::LevelFilter::Debug) | ||
.build(); | ||
|
||
simplelog::TermLogger::new( | ||
log_level, | ||
config, | ||
simplelog::TerminalMode::Mixed, | ||
simplelog::ColorChoice::Auto, | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
mutation ConsumeLoginSession($sessionId: String!) { | ||
consumeLoginSession(sessionId: $sessionId) { | ||
token | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
mutation CreateLoginSession { | ||
createLoginSession { | ||
callbackUrl | ||
sessionId | ||
} | ||
} |