Skip to content

Commit

Permalink
feat(auth): first implementation of auth login command
Browse files Browse the repository at this point in the history
  • Loading branch information
adriencaccia committed Jul 15, 2024
1 parent 832acd1 commit 4c17d5e
Show file tree
Hide file tree
Showing 9 changed files with 433 additions and 44 deletions.
181 changes: 143 additions & 38 deletions Cargo.lock

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,14 @@ tokio-tar = "0.3.1"
md5 = "0.7.0"
base64 = "0.21.0"
async-compression = { version = "0.4.5", features = ["tokio", "gzip"] }
simplelog = { version = "0.12.1", default-features = false }
simplelog = { version = "0.12.1", default-features = false, features = [
"termcolor",
] }
tempfile = "3.10.0"
git2 = "0.18.3"
nestify = "0.3.3"
gql_client = { git = "https://github.com/adriencaccia/gql-client-rs" }
serde_yaml = "0.9.34"

[dev-dependencies]
temp-env = { version = "0.3.6", features = ["async_closure"] }
Expand Down
5 changes: 4 additions & 1 deletion src/app.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::{prelude::*, run};
use crate::{auth, prelude::*, run};
use clap::{Parser, Subcommand};

#[derive(Parser, Debug)]
Expand All @@ -11,13 +11,16 @@ struct Cli {
enum Commands {
/// Run the bench command and upload the results to CodSpeed
Run(run::RunArgs),
/// Commands related to authentication with CodSpeed
Auth(auth::AuthArgs),
}

pub async fn run() -> Result<()> {
let cli = Cli::parse();

match cli.command {
Commands::Run(args) => run::run(args).await?,
Commands::Auth(args) => auth::run(args).await?,
}
Ok(())
}
166 changes: 166 additions & 0 deletions src/auth.rs
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(())
}
73 changes: 73 additions & 0 deletions src/config.rs
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(())
}
}
21 changes: 21 additions & 0 deletions src/logger.rs
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,
)
}
13 changes: 9 additions & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
mod app;
mod auth;
mod config;
mod logger;
mod prelude;
mod request_client;
mod run;
Expand All @@ -15,10 +18,12 @@ pub const VALGRIND_CODSPEED_VERSION: &str = "3.21.0-0codspeed1";
async fn main() {
let res = crate::app::run().await;
if let Err(err) = res {
if log_enabled!(log::Level::Error) {
error!("Error {}", err);
} else {
eprintln!("Error {}", err);
for cause in err.chain() {
if log_enabled!(log::Level::Error) {
error!("Error {}", cause);
} else {
eprintln!("Error {}", cause);
}
}
if log_enabled!(log::Level::Debug) {
for e in err.chain().skip(1) {
Expand Down
5 changes: 5 additions & 0 deletions src/queries/ConsumeLoginSession.gql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
mutation ConsumeLoginSession($sessionId: String!) {
consumeLoginSession(sessionId: $sessionId) {
token
}
}
6 changes: 6 additions & 0 deletions src/queries/CreateLoginSession.gql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
mutation CreateLoginSession {
createLoginSession {
callbackUrl
sessionId
}
}

0 comments on commit 4c17d5e

Please sign in to comment.