jira_issue_api/
client.rs

1use crate::models::*;
2use base64::{engine::general_purpose, Engine as _};
3use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE};
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/// JiraApiClient config object
30#[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/// Supported Authentication methods
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum Credential {
42    /// Anonymous
43    /// Omit Authorization header
44    Anonymous,
45    /// User email/username and token
46    /// Authorization: Basic <b64 login:token>
47    ApiToken { login: String, token: String },
48    /// Personal Access Token
49    /// Authorization: Bearer <PAT>
50    PersonalAccessToken(String),
51}
52
53/// Reusable client for interfacing with Jira
54#[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 = general_purpose::STANDARD_NO_PAD
78                    .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    /// Instantiate a reusable API client.
99    ///
100    /// ```rust
101    /// use jira_issue_api::models::*;
102    /// use jira_issue_api::{Credential, JiraClientConfig, JiraAPIClient};
103    ///
104    /// let anon = Credential::Anonymous;
105    ///
106    /// // let credential = Credential::PersonalAccessToken("xxxxxxx".to_string())
107    ///
108    /// // let api_token = Credential::ApiToken {
109    /// //     login: "user@example.com".to_string(),
110    /// //     token: "xxxxxxx".to_string(),
111    /// // };
112    ///
113    /// let jira_cfg = JiraClientConfig {
114    ///     credential: anon,
115    ///     max_query_results: 50u32,
116    ///     url: "https://domain.atlassian.net".to_string(),
117    ///     timeout: 10u64,
118    ///     tls_accept_invalid_certs: false,
119    /// };
120    ///
121    /// let client = JiraAPIClient::new(&jira_cfg).unwrap();
122    /// ```
123    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_options: 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: expand_options,
158            fields,
159        };
160
161        let res = self.client.post(url).json(&body).send().await?;
162
163        if !self.anonymous_access
164            && (res
165                .headers()
166                .get("x-seraph-loginreason")
167                .is_some_and(|e| e.to_str().unwrap_or_default() == "AUTHENTICATED_FAILED")
168                || res
169                    .headers()
170                    .get("x-ausername")
171                    .is_some_and(|e| e.to_str().unwrap_or_default() == "anonymous"))
172        {
173            return Err(JiraClientError::JiraQueryAuthenticationError());
174        }
175
176        let response = res.json::<PostIssueQueryResponseBody>().await?;
177        Ok(response)
178    }
179
180    pub async fn post_worklog(
181        &self,
182        issue_key: &IssueKey,
183        body: PostWorklogBody,
184    ) -> Result<Response, JiraClientError> {
185        let url = self.api_url(&format!("issue/{}/worklog", issue_key))?;
186
187        // If any pattern matches, do not prompt.
188        if matches!(
189            (body.time_spent.is_some(), body.time_spent_seconds.is_some()),
190            (false, false) | (true, true)
191        ) {
192            return Err(JiraClientError::JiraRequestBodyError(
193                "time_spent and time_spent_seconds are both 'Some()' or 'None'".to_string(),
194            ));
195        }
196
197        let response = self.client.post(url).json(&body).send().await?;
198        Ok(response)
199    }
200
201    pub async fn post_comment(
202        &self,
203        issue_key: &IssueKey,
204        body: PostCommentBody,
205    ) -> Result<Response, JiraClientError> {
206        let url = self.api_url(&format!("issue/{}/comment", issue_key))?;
207
208        let response = self.client.post(url).json(&body).send().await?;
209        Ok(response)
210    }
211
212    pub async fn get_issue(
213        &self,
214        issue_key: &IssueKey,
215        expand_options: Option<&str>,
216    ) -> Result<Issue, JiraClientError> {
217        let mut url = self.api_url(&format!("issue/{}", issue_key))?;
218
219        match expand_options {
220            Some(expand_options) if !expand_options.starts_with("expand=") => {
221                url.set_query(Some(&format!("expand={expand_options}")))
222            }
223            expand_options => url.set_query(expand_options),
224        }
225
226        let response = self.client.get(url).send().await?;
227        let body = response.json::<Issue>().await?;
228        Ok(body)
229    }
230
231    pub async fn get_transitions(
232        &self,
233        issue_key: &IssueKey,
234        expand_options: Option<&str>,
235    ) -> Result<GetTransitionsBody, JiraClientError> {
236        let mut url = self.api_url(&format!("issue/{}/transitions", issue_key))?;
237
238        if expand_options.is_none() {
239            url.set_query(Some("expand=transitions.fields"));
240        } else if expand_options.is_some() && expand_options.unwrap().starts_with("expand=") {
241            url.set_query(expand_options);
242        } else {
243            url.set_query(Some(&format!("expand={}", expand_options.unwrap())));
244        }
245
246        let response = self.client.get(url).send().await?;
247        let body = response.json::<GetTransitionsBody>().await?;
248        Ok(body)
249    }
250
251    pub async fn post_transition(
252        &self,
253        issue_key: &IssueKey,
254        transition: &PostTransitionBody,
255    ) -> Result<Response, JiraClientError> {
256        let url = self.api_url(&format!("issue/{}/transitions", issue_key))?;
257
258        let response = self.client.post(url).json(transition).send().await?;
259        Ok(response)
260    }
261
262    pub async fn get_assignable_users(
263        &self,
264        params: &GetAssignableUserParams,
265    ) -> Result<Vec<User>, JiraClientError> {
266        let mut url = self.api_url("user/assignable/search")?;
267        let mut query: String = format!("maxResults={}", params.max_results.unwrap_or(1000));
268
269        if params.project.is_none() && params.issue_key.is_none() {
270            Err(JiraClientError::JiraRequestBodyError(
271                "Both project and issue_key are None, define either to query for assignable users."
272                    .to_string(),
273            ))?
274        }
275
276        if let Some(issue_key) = params.issue_key.clone() {
277            query.push_str(&format!("&issueKey={}", issue_key));
278        }
279        if let Some(username) = params.username.clone() {
280            #[cfg(feature = "cloud")]
281            query.push_str(&format!("&query={}", username));
282            #[cfg(not(feature = "cloud"))]
283            query.push_str(&format!("&username={}", username));
284        }
285        if let Some(project) = params.project.clone() {
286            query.push_str(&format!("&project={}", project));
287        }
288
289        url.set_query(Some(&query));
290
291        let response = self.client.get(url).send().await?;
292        let body = response.json::<Vec<User>>().await?;
293        Ok(body)
294    }
295
296    pub async fn post_assign_user(
297        &self,
298        issue_key: &IssueKey,
299        user: &User,
300    ) -> Result<Response, JiraClientError> {
301        let url = self.api_url(&format!("issue/{}/assignee", issue_key))?;
302
303        let body = PostAssignBody::from(user.clone());
304        let response = self.client.put(url).json(&body).send().await?;
305        Ok(response)
306    }
307
308    /// cloud:       user.account_id
309    /// data-center: user.name
310    pub async fn get_user(&self, user: &str) -> Result<User, JiraClientError> {
311        let url = self.api_url("user")?;
312
313        let key = match cfg!(feature = "cloud") {
314            true => "accountId",
315            false => "username",
316        };
317
318        let response = self.client.get(url).query(&[(key, user)]).send().await?;
319        let body = response.json::<User>().await?;
320        Ok(body)
321    }
322
323    pub async fn get_fields(&self) -> Result<Vec<Field>, JiraClientError> {
324        let url = self.api_url("field")?;
325
326        let response = self.client.get(url).send().await?;
327        let body = response.json::<Vec<Field>>().await?;
328        Ok(body)
329    }
330
331    pub async fn get_filter(&self, id: &str) -> Result<Filter, JiraClientError> {
332        let url = self.api_url(&format!("filter/{}", id))?;
333
334        let response = self.client.get(url).send().await?;
335        let body = response.json::<Filter>().await?;
336        Ok(body)
337    }
338
339    #[cfg(feature = "cloud")]
340    pub async fn search_filters(
341        &self,
342        filter: Option<&str>,
343    ) -> Result<GetFilterSearchResponseBody, JiraClientError> {
344        let mut url = self.api_url("filter/search")?;
345        let query = if let Some(filter) = filter {
346            format!(
347                "expand=jql&maxResults={}&filterName={}",
348                self.max_results, filter
349            )
350        } else {
351            format!("expand=jql&maxResults={}", self.max_results)
352        };
353
354        url.set_query(Some(&query));
355
356        let response = self.client.get(url).send().await?;
357        let body = response.json::<GetFilterSearchResponseBody>().await?;
358        Ok(body)
359    }
360}