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