1use crate::models::*;
2use base64::{Engine as _, engine::general_purpose};
3use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue};
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 =
78 general_purpose::STANDARD_NO_PAD.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: 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,
158 fields,
159 };
160
161 let res = self.client.post(url).json(&body).send().await?;
166
167 if !self.anonymous_access
168 && (res
169 .headers()
170 .get("x-seraph-loginreason")
171 .is_some_and(|e| e.to_str().unwrap_or_default() == "AUTHENTICATED_FAILED")
172 || res
173 .headers()
174 .get("x-ausername")
175 .is_some_and(|e| e.to_str().unwrap_or_default() == "anonymous"))
176 {
177 return Err(JiraClientError::JiraQueryAuthenticationError());
178 }
179
180 let response = res.json::<PostIssueQueryResponseBody>().await?;
181 Ok(response)
182 }
183
184 pub async fn post_worklog(
185 &self,
186 issue_key: &IssueKey,
187 body: PostWorklogBody,
188 ) -> Result<Response, JiraClientError> {
189 let url = self.api_url(&format!("issue/{issue_key}/worklog"))?;
190
191 if matches!(
193 (body.time_spent.is_some(), body.time_spent_seconds.is_some()),
194 (false, false) | (true, true)
195 ) {
196 return Err(JiraClientError::JiraRequestBodyError(
197 "time_spent and time_spent_seconds are both 'Some()' or 'None'".to_string(),
198 ));
199 }
200
201 let response = self.client.post(url).json(&body).send().await?;
202 Ok(response)
203 }
204
205 pub async fn post_comment(
206 &self,
207 issue_key: &IssueKey,
208 body: PostCommentBody,
209 ) -> Result<Response, JiraClientError> {
210 let url = self.api_url(&format!("issue/{issue_key}/comment"))?;
211
212 let response = self.client.post(url).json(&body).send().await?;
213 Ok(response)
214 }
215
216 pub async fn get_issue(
217 &self,
218 issue_key: &IssueKey,
219 expand_options: Option<&str>,
220 ) -> Result<Issue, JiraClientError> {
221 let mut url = self.api_url(&format!("issue/{issue_key}"))?;
222
223 match expand_options {
224 Some(expand_options) if !expand_options.starts_with("expand=") => {
225 url.set_query(Some(&format!("expand={expand_options}")))
226 }
227 expand_options => url.set_query(expand_options),
228 }
229
230 let response = self.client.get(url).send().await?;
231 let body = response.json::<Issue>().await?;
232 Ok(body)
233 }
234
235 pub async fn get_transitions(
236 &self,
237 issue_key: &IssueKey,
238 expand_options: Option<&str>,
239 ) -> Result<GetTransitionsBody, JiraClientError> {
240 let mut url = self.api_url(&format!("issue/{issue_key}/transitions"))?;
241
242 match expand_options {
243 None => url.set_query(Some("expand=transitions.fields")),
244 Some(e) if e.starts_with("expand=") => url.set_query(expand_options),
245 Some(e) => url.set_query(Some(&format!("expand={}", e))),
246 }
247
248 let response = self.client.get(url).send().await?;
249 let body = response.json::<GetTransitionsBody>().await?;
250 Ok(body)
251 }
252
253 pub async fn post_transition(
254 &self,
255 issue_key: &IssueKey,
256 transition: &PostTransitionBody,
257 ) -> Result<Response, JiraClientError> {
258 let url = self.api_url(&format!("issue/{issue_key}/transitions"))?;
259
260 let response = self.client.post(url).json(transition).send().await?;
261 Ok(response)
262 }
263
264 pub async fn get_assignable_users(
265 &self,
266 params: &GetAssignableUserParams,
267 ) -> Result<Vec<User>, JiraClientError> {
268 let mut url = self.api_url("user/assignable/search")?;
269 let mut query: String = format!("maxResults={}", params.max_results.unwrap_or(1000));
270
271 if params.project.is_none() && params.issue_key.is_none() {
272 Err(JiraClientError::JiraRequestBodyError(
273 "Both project and issue_key are None, define either to query for assignable users."
274 .to_string(),
275 ))?
276 }
277
278 if let Some(issue_key) = params.issue_key.clone() {
279 query.push_str(&format!("&issueKey={issue_key}"));
280 }
281 if let Some(username) = params.username.clone() {
282 query.push_str(&format!("&username={username}"));
283 }
284 if let Some(project) = params.project.clone() {
285 query.push_str(&format!("&project={project}"));
286 }
287
288 url.set_query(Some(&query));
289
290 let response = self.client.get(url).send().await?;
291 let body = response.json::<Vec<User>>().await?;
292 Ok(body)
293 }
294
295 pub async fn post_assign_user(
296 &self,
297 issue_key: &IssueKey,
298 user: &User,
299 ) -> Result<Response, JiraClientError> {
300 let url = self.api_url(&format!("issue/{issue_key}/assignee"))?;
301
302 let body = PostAssignBody::from(user.clone());
303 let response = self.client.put(url).json(&body).send().await?;
304 Ok(response)
305 }
306
307 pub async fn get_user(&self, user: &str) -> Result<User, JiraClientError> {
308 let url = self.api_url("user")?;
309
310 let response = self
311 .client
312 .get(url)
313 .query(&[("username", user)])
314 .send()
315 .await?;
316 let body = response.json::<User>().await?;
317 Ok(body)
318 }
319
320 pub async fn get_fields(&self) -> Result<Vec<Field>, JiraClientError> {
321 let url = self.api_url("field")?;
322
323 let response = self.client.get(url).send().await?;
324 let body = response.json::<Vec<Field>>().await?;
325 Ok(body)
326 }
327
328 pub async fn get_filter(&self, id: &str) -> Result<Filter, JiraClientError> {
329 let url = self.api_url(&format!("filter/{id}"))?;
330
331 let response = self.client.get(url).send().await?;
332 let body = response.json::<Filter>().await?;
333 Ok(body)
334 }
335}