1use crate::models::*;
2use base64::{engine::general_purpose, Engine as _};
3use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE};
4use reqwest::{Client, ClientBuilder, Response, Url};
5use std::{convert::From, time::Duration};
6use thiserror::Error;
7use url::ParseError;
8
9#[derive(Error, Debug)]
10pub enum JiraClientError {
11 #[error("Request failed")]
12 HttpError(#[from] reqwest::Error),
13 #[error("Authentication failed")]
14 JiraQueryAuthenticationError(),
15 #[error("Body malformed or invalid: {0}")]
16 JiraRequestBodyError(String),
17 #[error("Unable to parse response: {0}")]
18 JiraResponseDeserializeError(String),
19 #[error("Unable to build JiraAPIClient struct:{0}")]
20 ConfigError(String),
21 #[error("Unable to parse Url: {0}")]
22 UrlParseError(#[from] ParseError),
23 #[error("{0}")]
24 TryFromError(String),
25 #[error("{0}")]
26 UnknownError(String),
27}
28
29#[derive(Debug, Clone)]
31pub struct JiraClientConfig {
32 pub credential: Credential,
33 pub max_query_results: u32,
34 pub url: String,
35 pub timeout: u64,
36 pub tls_accept_invalid_certs: bool,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum Credential {
42 Anonymous,
45 ApiToken { login: String, token: String },
48 PersonalAccessToken(String),
51}
52
53#[derive(Debug, Clone)]
55pub struct JiraAPIClient {
56 pub url: Url,
57
58 pub(crate) client: Client,
59 pub(crate) anonymous_access: bool,
60 pub(crate) max_results: u32,
61}
62
63impl JiraAPIClient {
64 fn api_url(&self, path: &str) -> Result<Url, JiraClientError> {
65 Ok(self.url.join(&format!("rest/api/latest/{}", path))?)
66 }
67
68 fn build_headers(credentials: &Credential) -> HeaderMap {
69 let header_content = HeaderValue::from_static("application/json");
70
71 let auth_header = match credentials {
72 Credential::Anonymous => None,
73 Credential::ApiToken {
74 login: user_login,
75 token: api_token,
76 } => {
77 let jira_encoded_auth = general_purpose::STANDARD_NO_PAD
78 .encode(format!("{}:{}", user_login, api_token,));
79 Some(HeaderValue::from_str(&format!("Basic {}", jira_encoded_auth)).unwrap())
80 }
81 Credential::PersonalAccessToken(token) => {
82 Some(HeaderValue::from_str(&format!("Bearer {}", token)).unwrap())
83 }
84 };
85
86 let mut headers = HeaderMap::new();
87 headers.insert(ACCEPT, header_content.clone());
88 headers.insert(CONTENT_TYPE, header_content);
89
90 if let Some(mut auth_header_value) = auth_header {
91 auth_header_value.set_sensitive(true);
92 headers.insert(AUTHORIZATION, auth_header_value);
93 }
94
95 headers
96 }
97
98 pub fn new(cfg: &JiraClientConfig) -> Result<JiraAPIClient, JiraClientError> {
124 let client = ClientBuilder::new()
125 .default_headers(JiraAPIClient::build_headers(&cfg.credential))
126 .danger_accept_invalid_certs(cfg.tls_accept_invalid_certs)
127 .https_only(true)
128 .timeout(Duration::from_secs(cfg.timeout))
129 .connection_verbose(false)
130 .build()?;
131
132 let mut url = Url::parse(&cfg.url)?;
133 url.set_path("/");
134 url.set_query(None);
135 url.set_fragment(None);
136
137 Ok(JiraAPIClient {
138 url,
139 client,
140 max_results: cfg.max_query_results,
141 anonymous_access: cfg.credential.eq(&Credential::Anonymous),
142 })
143 }
144
145 pub async fn query_issues(
146 &self,
147 query: &str,
148 fields: Option<Vec<String>>,
149 expand_options: Option<Vec<String>>,
150 ) -> Result<PostIssueQueryResponseBody, JiraClientError> {
151 let url = self.api_url("search")?;
152
153 let body = PostIssueQueryBody {
154 jql: query.to_owned(),
155 start_at: 0,
156 max_results: self.max_results,
157 expand: expand_options,
158 fields,
159 };
160
161 let res = self.client.post(url).json(&body).send().await?;
162
163 if !self.anonymous_access
164 && (res
165 .headers()
166 .get("x-seraph-loginreason")
167 .is_some_and(|e| e.to_str().unwrap_or_default() == "AUTHENTICATED_FAILED")
168 || res
169 .headers()
170 .get("x-ausername")
171 .is_some_and(|e| e.to_str().unwrap_or_default() == "anonymous"))
172 {
173 return Err(JiraClientError::JiraQueryAuthenticationError());
174 }
175
176 let response = res.json::<PostIssueQueryResponseBody>().await?;
177 Ok(response)
178 }
179
180 pub async fn post_worklog(
181 &self,
182 issue_key: &IssueKey,
183 body: PostWorklogBody,
184 ) -> Result<Response, JiraClientError> {
185 let url = self.api_url(&format!("issue/{}/worklog", issue_key))?;
186
187 if matches!(
189 (body.time_spent.is_some(), body.time_spent_seconds.is_some()),
190 (false, false) | (true, true)
191 ) {
192 return Err(JiraClientError::JiraRequestBodyError(
193 "time_spent and time_spent_seconds are both 'Some()' or 'None'".to_string(),
194 ));
195 }
196
197 let response = self.client.post(url).json(&body).send().await?;
198 Ok(response)
199 }
200
201 pub async fn post_comment(
202 &self,
203 issue_key: &IssueKey,
204 body: PostCommentBody,
205 ) -> Result<Response, JiraClientError> {
206 let url = self.api_url(&format!("issue/{}/comment", issue_key))?;
207
208 let response = self.client.post(url).json(&body).send().await?;
209 Ok(response)
210 }
211
212 pub async fn get_issue(
213 &self,
214 issue_key: &IssueKey,
215 expand_options: Option<&str>,
216 ) -> Result<Issue, JiraClientError> {
217 let mut url = self.api_url(&format!("issue/{}", issue_key))?;
218
219 match expand_options {
220 Some(expand_options) if !expand_options.starts_with("expand=") => {
221 url.set_query(Some(&format!("expand={expand_options}")))
222 }
223 expand_options => url.set_query(expand_options),
224 }
225
226 let response = self.client.get(url).send().await?;
227 let body = response.json::<Issue>().await?;
228 Ok(body)
229 }
230
231 pub async fn get_transitions(
232 &self,
233 issue_key: &IssueKey,
234 expand_options: Option<&str>,
235 ) -> Result<GetTransitionsBody, JiraClientError> {
236 let mut url = self.api_url(&format!("issue/{}/transitions", issue_key))?;
237
238 if expand_options.is_none() {
239 url.set_query(Some("expand=transitions.fields"));
240 } else if expand_options.is_some() && expand_options.unwrap().starts_with("expand=") {
241 url.set_query(expand_options);
242 } else {
243 url.set_query(Some(&format!("expand={}", expand_options.unwrap())));
244 }
245
246 let response = self.client.get(url).send().await?;
247 let body = response.json::<GetTransitionsBody>().await?;
248 Ok(body)
249 }
250
251 pub async fn post_transition(
252 &self,
253 issue_key: &IssueKey,
254 transition: &PostTransitionBody,
255 ) -> Result<Response, JiraClientError> {
256 let url = self.api_url(&format!("issue/{}/transitions", issue_key))?;
257
258 let response = self.client.post(url).json(transition).send().await?;
259 Ok(response)
260 }
261
262 pub async fn get_assignable_users(
263 &self,
264 params: &GetAssignableUserParams,
265 ) -> Result<Vec<User>, JiraClientError> {
266 let mut url = self.api_url("user/assignable/search")?;
267 let mut query: String = format!("maxResults={}", params.max_results.unwrap_or(1000));
268
269 if params.project.is_none() && params.issue_key.is_none() {
270 Err(JiraClientError::JiraRequestBodyError(
271 "Both project and issue_key are None, define either to query for assignable users."
272 .to_string(),
273 ))?
274 }
275
276 if let Some(issue_key) = params.issue_key.clone() {
277 query.push_str(&format!("&issueKey={}", issue_key));
278 }
279 if let Some(username) = params.username.clone() {
280 #[cfg(feature = "cloud")]
281 query.push_str(&format!("&query={}", username));
282 #[cfg(not(feature = "cloud"))]
283 query.push_str(&format!("&username={}", username));
284 }
285 if let Some(project) = params.project.clone() {
286 query.push_str(&format!("&project={}", project));
287 }
288
289 url.set_query(Some(&query));
290
291 let response = self.client.get(url).send().await?;
292 let body = response.json::<Vec<User>>().await?;
293 Ok(body)
294 }
295
296 pub async fn post_assign_user(
297 &self,
298 issue_key: &IssueKey,
299 user: &User,
300 ) -> Result<Response, JiraClientError> {
301 let url = self.api_url(&format!("issue/{}/assignee", issue_key))?;
302
303 let body = PostAssignBody::from(user.clone());
304 let response = self.client.put(url).json(&body).send().await?;
305 Ok(response)
306 }
307
308 pub async fn get_user(&self, user: &str) -> Result<User, JiraClientError> {
311 let url = self.api_url("user")?;
312
313 let key = match cfg!(feature = "cloud") {
314 true => "accountId",
315 false => "username",
316 };
317
318 let response = self.client.get(url).query(&[(key, user)]).send().await?;
319 let body = response.json::<User>().await?;
320 Ok(body)
321 }
322
323 pub async fn get_fields(&self) -> Result<Vec<Field>, JiraClientError> {
324 let url = self.api_url("field")?;
325
326 let response = self.client.get(url).send().await?;
327 let body = response.json::<Vec<Field>>().await?;
328 Ok(body)
329 }
330
331 pub async fn get_filter(&self, id: &str) -> Result<Filter, JiraClientError> {
332 let url = self.api_url(&format!("filter/{}", id))?;
333
334 let response = self.client.get(url).send().await?;
335 let body = response.json::<Filter>().await?;
336 Ok(body)
337 }
338
339 #[cfg(feature = "cloud")]
340 pub async fn search_filters(
341 &self,
342 filter: Option<&str>,
343 ) -> Result<GetFilterSearchResponseBody, JiraClientError> {
344 let mut url = self.api_url("filter/search")?;
345 let query = if let Some(filter) = filter {
346 format!(
347 "expand=jql&maxResults={}&filterName={}",
348 self.max_results, filter
349 )
350 } else {
351 format!("expand=jql&maxResults={}", self.max_results)
352 };
353
354 url.set_query(Some(&query));
355
356 let response = self.client.get(url).send().await?;
357 let body = response.json::<GetFilterSearchResponseBody>().await?;
358 Ok(body)
359 }
360}