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