use crate::models::*;
use base64::{Engine as _, engine::general_purpose};
use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue};
use reqwest::{Certificate, Client, ClientBuilder, Response, Url};
use std::{convert::From, time::Duration};
use thiserror::Error;
use url::ParseError;
#[derive(Error, Debug)]
pub enum JiraClientError {
#[error("Unable to reade file {0}")]
ReadFileError(#[from] std::io::Error),
#[error("Request failed")]
HttpError(#[from] reqwest::Error),
#[error("Authentication failed")]
JiraQueryAuthenticationError(),
#[error("Body malformed or invalid: {0}")]
JiraRequestBodyError(String),
#[error("Unable to parse response: {0}")]
JiraResponseDeserializeError(String),
#[error("Unable to build JiraAPIClient struct:{0}")]
ConfigError(String),
#[error("Unable to parse Url: {0}")]
UrlParseError(#[from] ParseError),
#[error("{0}")]
TryFromError(String),
#[error("{0}")]
UnknownError(String),
}
#[derive(Debug, Clone)]
pub struct JiraClientConfig {
pub credential: Credential,
pub max_query_results: u32,
pub url: String,
pub timeout: u64,
pub insecure_skip_tls_verify: bool,
pub ca_certificate: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Credential {
Anonymous,
ApiToken { login: String, token: String },
PersonalAccessToken(String),
}
#[derive(Debug, Clone)]
pub struct JiraAPIClient {
pub url: Url,
pub(crate) client: Client,
pub(crate) anonymous_access: bool,
pub(crate) max_results: u32,
}
impl JiraAPIClient {
fn api_url(&self, path: &str) -> Result<Url, JiraClientError> {
Ok(self.url.join(&format!("rest/api/latest/{path}"))?)
}
fn build_headers(credentials: &Credential) -> HeaderMap {
let header_content = HeaderValue::from_static("application/json");
let auth_header = match credentials {
Credential::Anonymous => None,
Credential::ApiToken {
login: user_login,
token: api_token,
} => {
let jira_encoded_auth =
general_purpose::STANDARD_NO_PAD.encode(format!("{user_login}:{api_token}"));
Some(HeaderValue::from_str(&format!("Basic {jira_encoded_auth}")).unwrap())
}
Credential::PersonalAccessToken(token) => {
Some(HeaderValue::from_str(&format!("Bearer {token}")).unwrap())
}
};
let mut headers = HeaderMap::new();
headers.insert(ACCEPT, header_content.clone());
headers.insert(CONTENT_TYPE, header_content);
if let Some(mut auth_header_value) = auth_header {
auth_header_value.set_sensitive(true);
headers.insert(AUTHORIZATION, auth_header_value);
}
headers
}
pub fn new(cfg: &JiraClientConfig) -> Result<JiraAPIClient, JiraClientError> {
let mut builder = ClientBuilder::new()
.default_headers(JiraAPIClient::build_headers(&cfg.credential))
.https_only(true)
.timeout(Duration::from_secs(cfg.timeout))
.danger_accept_invalid_certs(cfg.insecure_skip_tls_verify)
.connection_verbose(false);
if let Some(ca) = cfg.ca_certificate.clone() {
builder = builder.tls_certs_only(vec![Certificate::from_pem(ca.as_bytes())?])
};
let mut url = Url::parse(&cfg.url)?;
url.set_path("/");
url.set_query(None);
url.set_fragment(None);
Ok(JiraAPIClient {
url,
client: builder.build()?,
max_results: cfg.max_query_results,
anonymous_access: cfg.credential.eq(&Credential::Anonymous),
})
}
pub async fn query_issues(
&self,
query: &str,
fields: Option<Vec<String>>,
expand: Option<Vec<String>>,
) -> Result<PostIssueQueryResponseBody, JiraClientError> {
let url = self.api_url("search")?;
let body = PostIssueQueryBody {
jql: query.to_owned(),
start_at: 0,
max_results: self.max_results,
expand,
fields,
};
let res = self.client.post(url).json(&body).send().await?;
if !self.anonymous_access
&& (res
.headers()
.get("x-seraph-loginreason")
.is_some_and(|e| e.to_str().unwrap_or_default() == "AUTHENTICATED_FAILED")
|| res
.headers()
.get("x-ausername")
.is_some_and(|e| e.to_str().unwrap_or_default() == "anonymous"))
{
return Err(JiraClientError::JiraQueryAuthenticationError());
}
let response = res.json::<PostIssueQueryResponseBody>().await?;
Ok(response)
}
pub async fn post_worklog(
&self,
issue_key: &IssueKey,
body: PostWorklogBody,
) -> Result<Response, JiraClientError> {
let url = self.api_url(&format!("issue/{issue_key}/worklog"))?;
if matches!(
(body.time_spent.is_some(), body.time_spent_seconds.is_some()),
(false, false) | (true, true)
) {
return Err(JiraClientError::JiraRequestBodyError(
"time_spent and time_spent_seconds are both 'Some()' or 'None'".to_string(),
));
}
let response: Response = self.client.post(url).json(&body).send().await?;
Ok(response)
}
pub async fn post_comment(
&self,
issue_key: &IssueKey,
body: PostCommentBody,
) -> Result<Response, JiraClientError> {
let url = self.api_url(&format!("issue/{issue_key}/comment"))?;
let response = self.client.post(url).json(&body).send().await?;
Ok(response)
}
pub async fn get_issue(
&self,
issue_key: &IssueKey,
expand_options: Option<&str>,
) -> Result<Issue, JiraClientError> {
let mut url = self.api_url(&format!("issue/{issue_key}"))?;
match expand_options {
Some(expand_options) if !expand_options.starts_with("expand=") => {
url.set_query(Some(&format!("expand={expand_options}")))
}
expand_options => url.set_query(expand_options),
}
let response = self.client.get(url).send().await?;
let body = response.json::<Issue>().await?;
Ok(body)
}
pub async fn get_transitions(
&self,
issue_key: &IssueKey,
expand_options: Option<&str>,
) -> Result<GetTransitionsBody, JiraClientError> {
let mut url = self.api_url(&format!("issue/{issue_key}/transitions"))?;
match expand_options {
None => url.set_query(Some("expand=transitions.fields")),
Some(e) if e.starts_with("expand=") => url.set_query(expand_options),
Some(e) => url.set_query(Some(&format!("expand={}", e))),
}
let response = self.client.get(url).send().await?;
let body = response.json::<GetTransitionsBody>().await?;
Ok(body)
}
pub async fn post_transition(
&self,
issue_key: &IssueKey,
transition: &PostTransitionBody,
) -> Result<Response, JiraClientError> {
let url = self.api_url(&format!("issue/{issue_key}/transitions"))?;
let response = self.client.post(url).json(transition).send().await?;
Ok(response)
}
pub async fn get_assignable_users(
&self,
params: &GetAssignableUserParams,
) -> Result<Vec<User>, JiraClientError> {
let mut url = self.api_url("user/assignable/search")?;
let mut query: String = format!("maxResults={}", params.max_results.unwrap_or(1000));
if params.project.is_none() && params.issue_key.is_none() {
Err(JiraClientError::JiraRequestBodyError(
"Both project and issue_key are None, define either to query for assignable users."
.to_string(),
))?
}
if let Some(issue_key) = params.issue_key.clone() {
query.push_str(&format!("&issueKey={issue_key}"));
}
if let Some(username) = params.username.clone() {
query.push_str(&format!("&username={username}"));
}
if let Some(project) = params.project.clone() {
query.push_str(&format!("&project={project}"));
}
url.set_query(Some(&query));
let response = self.client.get(url).send().await?;
let body = response.json::<Vec<User>>().await?;
Ok(body)
}
pub async fn post_assign_user(
&self,
issue_key: &IssueKey,
user: &User,
) -> Result<Response, JiraClientError> {
let url = self.api_url(&format!("issue/{issue_key}/assignee"))?;
let body = PostAssignBody::from(user.clone());
let response = self.client.put(url).json(&body).send().await?;
Ok(response)
}
pub async fn get_user(&self, user: &str) -> Result<User, JiraClientError> {
let mut url = self.api_url("user")?;
url.set_query(Some(&format!("username={user}")));
let response: Response = self.client.get(url).send().await?;
let body = response.json::<User>().await?;
Ok(body)
}
pub async fn get_fields(&self) -> Result<Vec<Field>, JiraClientError> {
let url = self.api_url("field")?;
let response = self.client.get(url).send().await?;
let body = response.json::<Vec<Field>>().await?;
Ok(body)
}
pub async fn get_filter(&self, id: &str) -> Result<Filter, JiraClientError> {
let url = self.api_url(&format!("filter/{id}"))?;
let response = self.client.get(url).send().await?;
let body = response.json::<Filter>().await?;
Ok(body)
}
}