use crate::errors::JiraQueryError;
use crate::issue_model::{CloudSearchResults, Issue, JqlResults};
const REST_PREFIX: &str = "rest/api/2";
const MAX_KEYS_PER_URL_CHUNK: usize = 50;
pub struct JiraInstance {
pub host: String,
pub auth: Auth,
pub pagination: Pagination,
client: reqwest::Client,
is_cloud: bool,
}
pub enum Auth {
Anonymous,
ApiKey(String),
Basic { user: String, password: String },
}
impl Default for Auth {
fn default() -> Self {
Self::Anonymous
}
}
pub enum Pagination {
Default,
MaxResults(u32),
ChunkSize(u32),
}
impl Default for Pagination {
fn default() -> Self {
Self::Default
}
}
enum Method<'a> {
Key(&'a str),
Keys(&'a [&'a str]),
Search(&'a str),
}
impl<'a> Method<'a> {
fn url_fragment(&self, is_cloud: bool) -> String {
match self {
Self::Key(id) => format!("issue/{id}"),
Self::Keys(ids) => {
let jql = format!("id%20in%20({})", ids.join(","));
if is_cloud {
format!("search/jql?jql={jql}")
} else {
format!("search?jql={jql}")
}
}
Self::Search(query) => {
if is_cloud {
format!("search/jql?jql={query}")
} else {
format!("search?jql={query}")
}
}
}
}
}
impl JiraInstance {
pub fn at(host: String) -> Result<Self, JiraQueryError> {
let client = reqwest::Client::new();
Ok(Self {
host,
client,
auth: Auth::default(),
pagination: Pagination::default(),
is_cloud: false,
})
}
#[must_use]
pub fn for_cloud(mut self) -> Self {
self.is_cloud = true;
self
}
#[must_use]
pub fn authenticate(mut self, auth: Auth) -> Self {
self.auth = auth;
self
}
#[must_use]
pub fn with_client(mut self, client: reqwest::Client) -> Self {
self.client = client;
self
}
#[must_use]
pub const fn paginate(mut self, pagination: Pagination) -> Self {
self.pagination = pagination;
self
}
#[must_use]
fn path(&self, method: &Method, start_at: u32) -> String {
let max_results = match self.pagination {
Pagination::Default => String::new(),
Pagination::MaxResults(n) | Pagination::ChunkSize(n) => format!("&maxResults={n}"),
};
let start_at = match method {
Method::Key(_) => String::new(),
Method::Keys(_) | Method::Search(_) => format!("&startAt={start_at}"),
};
let fields_param = match method {
Method::Key(_) => String::new(),
Method::Keys(_) | Method::Search(_) => "&fields=*all".to_string(),
};
format!(
"{}/{}/{}{}{}{}",
self.host,
REST_PREFIX,
method.url_fragment(self.is_cloud),
max_results,
start_at,
fields_param
)
}
#[must_use]
fn cloud_path(&self, method: &Method, next_page_token: Option<&str>) -> String {
let max_results = match self.pagination {
Pagination::Default => String::new(),
Pagination::MaxResults(n) | Pagination::ChunkSize(n) => format!("&maxResults={n}"),
};
let token_param = match next_page_token {
Some(token) => format!("&nextPageToken={token}"),
None => String::new(),
};
let fields_param = match method {
Method::Key(_) => String::new(),
Method::Keys(_) | Method::Search(_) => "&fields=*all".to_string(),
};
format!(
"{}/{}/{}{}{}{}",
self.host,
REST_PREFIX,
method.url_fragment(self.is_cloud),
max_results,
token_param,
fields_param
)
}
async fn authenticated_get(&self, url: &str) -> Result<reqwest::Response, reqwest::Error> {
let request_builder = self.client.get(url);
let authenticated = match &self.auth {
Auth::Anonymous => request_builder,
Auth::ApiKey(key) => request_builder.header("Authorization", &format!("Bearer {key}")),
Auth::Basic { user, password } => request_builder.basic_auth(user, Some(password)),
};
authenticated.send().await
}
pub async fn issue(&self, key: &str) -> Result<Issue, JiraQueryError> {
let url = self.path(&Method::Key(key), 0);
let issue = self.authenticated_get(&url).await?.json::<Issue>().await?;
log::debug!("{:#?}", issue);
Ok(issue)
}
pub async fn issues(&self, keys: &[&str]) -> Result<Vec<Issue>, JiraQueryError> {
if keys.is_empty() {
return Ok(Vec::new());
}
let mut all_collected_issues: Vec<Issue> = Vec::with_capacity(keys.len());
for key_chunk in keys.chunks(20) {
if key_chunk.is_empty() {
continue;
}
log::debug!(
"Fetching batch of {} keys: {}",
key_chunk.len(),
key_chunk.join(", ")
);
let method = Method::Keys(key_chunk);
if self.is_cloud {
let mut issues_from_chunk = self.cloud_paginated_issues(&method).await?;
all_collected_issues.append(&mut issues_from_chunk);
} else {
let mut issues_from_chunk = self.chunk_of_issues(&method, 0).await?;
all_collected_issues.append(&mut issues_from_chunk);
}
}
if !keys.is_empty() && all_collected_issues.is_empty() {
Err(JiraQueryError::NoIssues)
} else {
Ok(all_collected_issues)
}
}
async fn paginated_issues(
&self,
method: &Method<'_>,
chunk_size: u32,
) -> Result<Vec<Issue>, JiraQueryError> {
let mut all_issues = Vec::new();
let mut start_at = 0;
loop {
let mut chunk_issues = self.chunk_of_issues(method, start_at).await?;
let page_size = chunk_issues.len();
all_issues.append(&mut chunk_issues);
if page_size < chunk_size as usize {
break;
}
start_at += chunk_size;
}
Ok(all_issues)
}
async fn cloud_paginated_issues(
&self,
method: &Method<'_>,
) -> Result<Vec<Issue>, JiraQueryError> {
let mut all_issues = Vec::new();
let mut next_page_token: Option<String> = None;
loop {
let url = self.cloud_path(method, next_page_token.as_deref());
log::debug!("Cloud paginated request: {}", url);
let response = self.authenticated_get(&url).await?;
let results = response.json::<CloudSearchResults>().await?;
log::debug!("{:#?}", results);
let page_size = results.issues.len();
all_issues.extend(results.issues);
if page_size == 0 {
break;
}
if results.is_last.unwrap_or(false) {
break;
}
match results.next_page_token {
Some(token) if !token.is_empty() => {
next_page_token = Some(token);
}
_ => {
break;
}
}
}
Ok(all_issues)
}
async fn chunk_of_issues(
&self,
method: &Method<'_>,
start_at: u32,
) -> Result<Vec<Issue>, JiraQueryError> {
let url = self.path(method, start_at);
let results = self
.authenticated_get(&url)
.await?
.json::<JqlResults>()
.await?;
log::debug!("{:#?}", results);
Ok(results.issues)
}
pub async fn search(&self, query: &str) -> Result<Vec<Issue>, JiraQueryError> {
let method = Method::Search(query);
if self.is_cloud {
self.cloud_paginated_issues(&method).await
} else if let Pagination::ChunkSize(chunk_size) = self.pagination {
self.paginated_issues(&method, chunk_size).await
} else {
let issues = self.chunk_of_issues(&method, 0).await?;
Ok(issues)
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
let result = 2 + 2;
assert_eq!(result, 4);
}
}