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