diff --git a/Cargo.lock b/Cargo.lock index 6a7807c..100cc79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -982,7 +982,11 @@ name = "libjirac" version = "0.1.0" dependencies = [ "chrono", + "http", + "reqwest", "serde", + "thiserror", + "url", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 545ff29..467e0c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,8 @@ resolver = "2" [workspace.dependencies] clap = { version = "4.4", features = ["derive"] } reqwest = { version = "0.12", features = ["json"] } +http = "1" +url = "2" tokio = { version = "1.0", features = ["full"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" @@ -16,4 +18,5 @@ gray_matter = { version = "0.2", default-features = false, features = ["toml"] } open = "5.2" chrono = { version = "0.4", features = ["serde"] } tabwriter = "1.4" -crossterm = "0.28" \ No newline at end of file +crossterm = "0.28" +thiserror = "2" \ No newline at end of file diff --git a/crates/jirac/src/cmd/search.rs b/crates/jirac/src/cmd/search.rs index fd8dff2..7392c15 100644 --- a/crates/jirac/src/cmd/search.rs +++ b/crates/jirac/src/cmd/search.rs @@ -2,6 +2,8 @@ use crate::cli::FormatMode; use crate::jira_config::JiraConfig; use crate::term::hyperlink; use crossterm::style::{Color, Stylize}; +use libjirac::client::commands::SearchCommand; +use libjirac::client::JiraClient; use libjirac::entities::issue::JiraIssue; use std::io::Write; @@ -81,13 +83,10 @@ pub async fn exec(output: FormatMode, jql: &str) -> Result<(), Box x, - Err(reason) => { - eprintln!("Error fetching issues: {}", reason); - std::process::exit(1); - } - }; + let client = JiraClient::from(&config); + + let jql_cmd = SearchCommand::new(&jql); + let result = client.exec(jql_cmd).await?; match (output, result.issues.is_empty()) { (FormatMode::Pretty, false) => { diff --git a/crates/jirac/src/cmd/view.rs b/crates/jirac/src/cmd/view.rs index b868fff..f477b60 100644 --- a/crates/jirac/src/cmd/view.rs +++ b/crates/jirac/src/cmd/view.rs @@ -2,6 +2,8 @@ use crate::cli::FormatMode; use crate::jira_config::JiraConfig; use crate::term::hyperlink; use crossterm::style::{Color, Stylize}; +use libjirac::client::commands::{SearchCommand, SelfCommand}; +use libjirac::client::JiraClient; use libjirac::entities::issue::JiraIssue; use std::io::Write; @@ -124,13 +126,10 @@ pub async fn exec(output: FormatMode, issue_key: &str) -> Result<(), Box x, - Err(reason) => { - eprintln!("Error fetching issue: {}", reason); - std::process::exit(1); - } - }; + let client = JiraClient::from(&config); + + let jql_cmd = SearchCommand::new(&jql); + let matched_issues = client.exec(jql_cmd).await?; let matched_issue = match matched_issues.issues.first() { Some(x) => x, @@ -140,7 +139,8 @@ pub async fn exec(output: FormatMode, issue_key: &str) -> Result<(), Box::new(&matched_issue.href); + let fetched_issue = client.exec(self_cmd).await?; match output { FormatMode::Pretty => pretty_print(&fetched_issue)?, diff --git a/crates/jirac/src/jira_config.rs b/crates/jirac/src/jira_config.rs index d2be3df..778e22b 100644 --- a/crates/jirac/src/jira_config.rs +++ b/crates/jirac/src/jira_config.rs @@ -1,5 +1,6 @@ use config::{Config, ConfigError, File}; use directories::ProjectDirs; +use libjirac::client::JiraClient; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; @@ -66,3 +67,9 @@ impl JiraConfig { Ok(()) } } + +impl From<&JiraConfig> for JiraClient { + fn from(value: &JiraConfig) -> Self { + Self::new(&value.url, &value.email, &value.api_token) + } +} diff --git a/crates/jirac/src/jql.rs b/crates/jirac/src/jql.rs deleted file mode 100644 index de6fc6a..0000000 --- a/crates/jirac/src/jql.rs +++ /dev/null @@ -1,37 +0,0 @@ -use crate::jira_config::JiraConfig; -use libjirac::entities::issue::JiraIssue; -use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE}; -use serde::Deserialize; - -#[derive(Deserialize)] -pub struct JiraSearchResponse { - pub issues: Vec, - pub total: u32, -} - -pub async fn run( - config: &JiraConfig, - jql: &str, -) -> Result> { - let client = reqwest::Client::new(); - - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - - let query = [("jql", jql)]; - - let response = client - .get(format!("{}/rest/api/2/search", config.url)) - .basic_auth(&config.email, Some(&config.api_token)) - .headers(headers) - .query(&query) - .send() - .await?; - - if !response.status().is_success() { - let error_text = response.text().await?; - return Err(format!("Failed to fetch issues: {}", error_text).into()); - } - - Ok(response.json::().await?) -} diff --git a/crates/jirac/src/main.rs b/crates/jirac/src/main.rs index a2cd3c1..d01c6a9 100644 --- a/crates/jirac/src/main.rs +++ b/crates/jirac/src/main.rs @@ -1,7 +1,6 @@ mod cli; mod cmd; mod jira_config; -mod jql; mod term; use clap::Parser; diff --git a/crates/libjirac/Cargo.toml b/crates/libjirac/Cargo.toml index cda4626..93c3e98 100644 --- a/crates/libjirac/Cargo.toml +++ b/crates/libjirac/Cargo.toml @@ -5,4 +5,8 @@ edition = "2021" [dependencies] serde = { workspace = true } -chrono = { workspace = true } \ No newline at end of file +chrono = { workspace = true } +reqwest = { workspace = true } +http = { workspace = true } +url = { workspace = true } +thiserror = { workspace = true } \ No newline at end of file diff --git a/crates/libjirac/src/client.rs b/crates/libjirac/src/client.rs new file mode 100644 index 0000000..46685ba --- /dev/null +++ b/crates/libjirac/src/client.rs @@ -0,0 +1,118 @@ +pub mod commands; + +use http::header::CONTENT_TYPE; +use http::{HeaderMap, HeaderValue}; +use serde::de::DeserializeOwned; +use serde::Serialize; +use std::fmt::Debug; +use thiserror::Error; +use url::{ParseError, Url}; + +pub struct JiraClient { + pub url: String, + pub email: String, + pub api_token: String, +} + +pub trait JiraCommand { + type TResponse: DeserializeOwned + Clone; + type TPayload: Serialize; + const REQUEST_TYPE: JiraRequestType; + fn endpoint(&self) -> String; + fn request_body(&self) -> Option<&Self::TPayload>; + fn query_params(&self) -> Option>; +} + +pub enum JiraRequestType { + Read, + Create, + Update, + Delete, +} + +impl From for http::Method { + fn from(value: JiraRequestType) -> Self { + match value { + JiraRequestType::Read => Self::GET, + JiraRequestType::Create => Self::POST, + JiraRequestType::Update => Self::PATCH, + JiraRequestType::Delete => Self::DELETE, + } + } +} + +#[derive(Debug, Error)] +pub enum JiraClientError { + #[error(transparent)] + UrlParseError(#[from] ParseError), + #[error(transparent)] + ReqwestError(#[from] reqwest::Error), + #[error("API Error: {0}")] + ApiError(String), +} + +impl JiraClient { + pub fn new(url: &str, email: &str, api_token: &str) -> Self { + Self { + url: url.to_string(), + email: email.to_string(), + api_token: api_token.to_string(), + } + } + + pub async fn exec( + &self, + cmd: TCommand, + ) -> Result + where + TCommand: JiraCommand + Debug, + { + let client = reqwest::Client::new(); + + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + + let url = self.construct_url(&cmd.endpoint())?; + + let mut request_builder = client + .request(TCommand::REQUEST_TYPE.into(), url) + .basic_auth(&self.email, Some(&self.api_token)) + .headers(headers); + + if let Some(params) = cmd.query_params() { + request_builder = request_builder.query(¶ms); + } + + if let Some(body) = cmd.request_body() { + request_builder = request_builder.json(body); + } + + let response = match request_builder.send().await { + Ok(x) => x, + Err(reason) => return Err(JiraClientError::ReqwestError(reason)), + }; + + if !response.status().is_success() { + let error_text = response.text().await?; + return Err(JiraClientError::ApiError(error_text)); + } + + match response.json::().await { + Ok(x) => Ok(x), + Err(reason) => Err(JiraClientError::ReqwestError(reason)), + } + } + + fn construct_url(&self, endpoint: &str) -> Result { + let mut url = match Url::parse(&self.url) { + Ok(x) => x, + Err(reason) => { + return Err(JiraClientError::UrlParseError(reason)); + } + }; + + url.set_path(endpoint); + + Ok(url.to_string()) + } +} diff --git a/crates/libjirac/src/client/commands.rs b/crates/libjirac/src/client/commands.rs new file mode 100644 index 0000000..3985e21 --- /dev/null +++ b/crates/libjirac/src/client/commands.rs @@ -0,0 +1,5 @@ +mod search_command; +mod self_command; + +pub use search_command::SearchCommand; +pub use self_command::SelfCommand; diff --git a/crates/libjirac/src/client/commands/search_command.rs b/crates/libjirac/src/client/commands/search_command.rs new file mode 100644 index 0000000..b44e4b8 --- /dev/null +++ b/crates/libjirac/src/client/commands/search_command.rs @@ -0,0 +1,35 @@ +use crate::client::{JiraCommand, JiraRequestType}; +use crate::entities::search::JiraSearchResponse; + +#[derive(Debug)] +pub struct SearchCommand { + jql: String, +} + +impl SearchCommand { + pub fn new(jql: &str) -> Self { + Self { + jql: jql.to_string(), + } + } +} + +impl JiraCommand for SearchCommand { + type TResponse = JiraSearchResponse; + type TPayload = (); + const REQUEST_TYPE: JiraRequestType = JiraRequestType::Read; + + fn endpoint(&self) -> String { + "/rest/api/2/search".to_string() + } + + fn request_body(&self) -> Option<&Self::TPayload> { + None + } + + fn query_params(&self) -> Option> { + let params = vec![("jql".to_string(), self.jql.clone())]; + + Some(params) + } +} diff --git a/crates/libjirac/src/client/commands/self_command.rs b/crates/libjirac/src/client/commands/self_command.rs new file mode 100644 index 0000000..bfee0f9 --- /dev/null +++ b/crates/libjirac/src/client/commands/self_command.rs @@ -0,0 +1,46 @@ +use crate::client::{JiraCommand, JiraRequestType}; +use serde::de::DeserializeOwned; +use url::Url; + +#[derive(Debug)] +pub struct SelfCommand +where + T: DeserializeOwned + Clone, +{ + href: String, + _marker: std::marker::PhantomData, +} + +impl SelfCommand +where + T: DeserializeOwned + Clone, +{ + pub fn new(href: &str) -> Self { + Self { + href: href.to_owned(), + _marker: std::marker::PhantomData, + } + } +} + +impl JiraCommand for SelfCommand +where + T: DeserializeOwned + Clone, +{ + type TResponse = T; + type TPayload = (); + const REQUEST_TYPE: JiraRequestType = JiraRequestType::Read; + + fn endpoint(&self) -> String { + let url = Url::parse(&self.href).unwrap(); + url.path().to_string() + } + + fn request_body(&self) -> Option<&Self::TPayload> { + None + } + + fn query_params(&self) -> Option> { + None + } +} diff --git a/crates/libjirac/src/entities.rs b/crates/libjirac/src/entities.rs index d93d369..4e94758 100644 --- a/crates/libjirac/src/entities.rs +++ b/crates/libjirac/src/entities.rs @@ -1 +1,2 @@ pub mod issue; +pub mod search; diff --git a/crates/libjirac/src/entities/issue.rs b/crates/libjirac/src/entities/issue.rs index bfdfe57..a576a7b 100644 --- a/crates/libjirac/src/entities/issue.rs +++ b/crates/libjirac/src/entities/issue.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct JiraIssue { pub key: String, #[serde(rename = "self")] @@ -8,7 +8,7 @@ pub struct JiraIssue { pub fields: JiraIssueResponseFields, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct JiraIssueResponseFields { pub summary: String, pub description: Option, @@ -24,12 +24,12 @@ pub struct JiraIssueResponseFields { pub votes: Votes, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct Status { pub name: String, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct Priority { pub name: String, pub id: String, @@ -69,7 +69,7 @@ pub struct Comment { pub created: chrono::DateTime, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct Votes { #[serde(rename = "self")] pub href: String, diff --git a/crates/libjirac/src/entities/search.rs b/crates/libjirac/src/entities/search.rs new file mode 100644 index 0000000..9ae48a3 --- /dev/null +++ b/crates/libjirac/src/entities/search.rs @@ -0,0 +1,8 @@ +use crate::entities::issue::JiraIssue; +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +pub struct JiraSearchResponse { + pub issues: Vec, + pub total: u32, +} diff --git a/crates/libjirac/src/lib.rs b/crates/libjirac/src/lib.rs index 0b8f0b5..5ea289c 100644 --- a/crates/libjirac/src/lib.rs +++ b/crates/libjirac/src/lib.rs @@ -1 +1,2 @@ +pub mod client; pub mod entities;