Skip to main content

jira_issue_api/
client.rs

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/// JiraApiClient config object
32#[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    /// PEM encoded certificate file
40    pub ca_certificate: Option<String>,
41}
42
43/// Supported Authentication methods
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum Credential {
46    /// Anonymous
47    /// Omit Authorization header
48    Anonymous,
49    /// User email/username and token
50    /// Authorization: Basic Base64_token
51    ApiToken { login: String, token: String },
52    /// Personal Access Token
53    /// Authorization: Bearer PersonalAccessToken(PAT)
54    PersonalAccessToken(String),
55}
56
57/// Reusable client for interfacing with Jira
58#[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    /// Instantiate a reusable API client.
103    ///
104    /// ```rust
105    /// use jira_issue_api::models::*;
106    /// use jira_issue_api::{Credential, JiraClientConfig, JiraAPIClient};
107    ///
108    /// let anon = Credential::Anonymous;
109    ///
110    /// // let credential = Credential::PersonalAccessToken("xxxxxxx".to_string())
111    ///
112    /// // let api_token = Credential::ApiToken {
113    /// //     login: "user@example.com".to_string(),
114    /// //     token: "xxxxxxx".to_string(),
115    /// // };
116    ///
117    /// let jira_cfg = JiraClientConfig {
118    ///     credential: anon,
119    ///     max_query_results: 50u32,
120    ///     url: "https://domain.atlassian.net".to_string(),
121    ///     timeout: 10u64,
122    ///     insecure_skip_tls_verify: false,
123    ///     ca_certificate: None,
124    /// };
125    ///
126    /// let client = JiraAPIClient::new(&jira_cfg).unwrap();
127    /// ```
128    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        // Scuffed Debug
170        // let res2 = self.client.post(url.clone()).json(&body).send().await?;
171        // let _ = std::fs::write("/home/ste/res.json", res2.text().await.unwrap());
172
173        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 any pattern matches, do not prompt.
200        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}