lib_client_jira/
client.rs

1use reqwest::header::HeaderMap;
2use serde::de::DeserializeOwned;
3use std::sync::Arc;
4use tracing::{debug, warn};
5
6use crate::auth::AuthStrategy;
7use crate::error::{Error, Result};
8use crate::types::*;
9
10pub struct ClientBuilder<A> {
11    auth: A,
12    base_url: Option<String>,
13}
14
15impl ClientBuilder<()> {
16    pub fn new() -> Self {
17        Self {
18            auth: (),
19            base_url: None,
20        }
21    }
22
23    pub fn auth<S: AuthStrategy + 'static>(self, auth: S) -> ClientBuilder<S> {
24        ClientBuilder {
25            auth,
26            base_url: self.base_url,
27        }
28    }
29}
30
31impl Default for ClientBuilder<()> {
32    fn default() -> Self {
33        Self::new()
34    }
35}
36
37impl<A: AuthStrategy + 'static> ClientBuilder<A> {
38    /// Set the Jira instance URL (e.g., "https://your-domain.atlassian.net").
39    pub fn base_url(mut self, url: impl Into<String>) -> Self {
40        self.base_url = Some(url.into());
41        self
42    }
43
44    pub fn build(self) -> Result<Client> {
45        let base_url = self
46            .base_url
47            .ok_or_else(|| Error::InvalidRequest("base_url is required".to_string()))?;
48
49        Ok(Client {
50            http: reqwest::Client::new(),
51            auth: Arc::new(self.auth),
52            base_url,
53        })
54    }
55}
56
57#[derive(Clone)]
58pub struct Client {
59    http: reqwest::Client,
60    auth: Arc<dyn AuthStrategy>,
61    base_url: String,
62}
63
64impl Client {
65    pub fn builder() -> ClientBuilder<()> {
66        ClientBuilder::new()
67    }
68
69    async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
70        self.request(reqwest::Method::GET, path, None::<&()>).await
71    }
72
73    async fn post<T: DeserializeOwned, B: serde::Serialize>(
74        &self,
75        path: &str,
76        body: &B,
77    ) -> Result<T> {
78        self.request(reqwest::Method::POST, path, Some(body)).await
79    }
80
81    async fn put<B: serde::Serialize>(&self, path: &str, body: &B) -> Result<()> {
82        self.request_no_response(reqwest::Method::PUT, path, Some(body))
83            .await
84    }
85
86    async fn request<T: DeserializeOwned>(
87        &self,
88        method: reqwest::Method,
89        path: &str,
90        body: Option<&impl serde::Serialize>,
91    ) -> Result<T> {
92        let url = format!("{}/rest/api/3{}", self.base_url, path);
93        debug!("Jira API request: {} {}", method, url);
94
95        let mut headers = HeaderMap::new();
96        self.auth.apply(&mut headers).await?;
97        headers.insert("Content-Type", "application/json".parse().unwrap());
98
99        let mut request = self.http.request(method, &url).headers(headers);
100
101        if let Some(body) = body {
102            request = request.json(body);
103        }
104
105        let response = request.send().await?;
106        self.handle_response(response).await
107    }
108
109    async fn request_no_response(
110        &self,
111        method: reqwest::Method,
112        path: &str,
113        body: Option<&impl serde::Serialize>,
114    ) -> Result<()> {
115        let url = format!("{}/rest/api/3{}", self.base_url, path);
116        debug!("Jira API request: {} {}", method, url);
117
118        let mut headers = HeaderMap::new();
119        self.auth.apply(&mut headers).await?;
120        headers.insert("Content-Type", "application/json".parse().unwrap());
121
122        let mut request = self.http.request(method, &url).headers(headers);
123
124        if let Some(body) = body {
125            request = request.json(body);
126        }
127
128        let response = request.send().await?;
129        let status = response.status();
130
131        if status.is_success() {
132            Ok(())
133        } else {
134            let status_code = status.as_u16();
135            let body = response.text().await.unwrap_or_default();
136            warn!("Jira API error ({}): {}", status_code, body);
137
138            match status_code {
139                401 => Err(Error::Unauthorized),
140                403 => Err(Error::Forbidden(body)),
141                404 => Err(Error::NotFound(body)),
142                429 => Err(Error::RateLimited { retry_after: 60 }),
143                _ => Err(Error::Api {
144                    status: status_code,
145                    message: body,
146                }),
147            }
148        }
149    }
150
151    async fn handle_response<T: DeserializeOwned>(&self, response: reqwest::Response) -> Result<T> {
152        let status = response.status();
153
154        if status.is_success() {
155            let body = response.text().await?;
156            serde_json::from_str(&body).map_err(Error::from)
157        } else {
158            let status_code = status.as_u16();
159            let body = response.text().await.unwrap_or_default();
160            warn!("Jira API error ({}): {}", status_code, body);
161
162            match status_code {
163                401 => Err(Error::Unauthorized),
164                403 => Err(Error::Forbidden(body)),
165                404 => Err(Error::NotFound(body)),
166                429 => Err(Error::RateLimited { retry_after: 60 }),
167                _ => Err(Error::Api {
168                    status: status_code,
169                    message: body,
170                }),
171            }
172        }
173    }
174
175    /// Get an issue by key (e.g., "PROJ-123").
176    pub async fn get_issue(&self, key: &str) -> Result<Issue> {
177        self.get(&format!("/issue/{}", key)).await
178    }
179
180    /// Create an issue.
181    pub async fn create_issue(&self, input: CreateIssueInput) -> Result<CreatedIssue> {
182        self.post("/issue", &input).await
183    }
184
185    /// Update an issue.
186    pub async fn update_issue(&self, key: &str, input: UpdateIssueInput) -> Result<()> {
187        self.put(&format!("/issue/{}", key), &input).await
188    }
189
190    /// Search issues using JQL.
191    pub async fn search_issues(&self, jql: &str, max_results: Option<u32>) -> Result<SearchResult> {
192        let max = max_results.unwrap_or(50);
193        let encoded_jql = urlencoding::encode(jql);
194        self.get(&format!("/search?jql={}&maxResults={}", encoded_jql, max))
195            .await
196    }
197
198    /// List all projects.
199    pub async fn list_projects(&self) -> Result<Vec<Project>> {
200        self.get("/project").await
201    }
202
203    /// Get a project by key.
204    pub async fn get_project(&self, key: &str) -> Result<Project> {
205        self.get(&format!("/project/{}", key)).await
206    }
207
208    /// Add a comment to an issue.
209    pub async fn add_comment(&self, issue_key: &str, input: AddCommentInput) -> Result<Comment> {
210        self.post(&format!("/issue/{}/comment", issue_key), &input)
211            .await
212    }
213
214    /// Get available transitions for an issue.
215    pub async fn get_transitions(&self, issue_key: &str) -> Result<Vec<Transition>> {
216        #[derive(serde::Deserialize)]
217        struct Response {
218            transitions: Vec<Transition>,
219        }
220
221        let resp: Response = self.get(&format!("/issue/{}/transitions", issue_key)).await?;
222        Ok(resp.transitions)
223    }
224
225    /// Transition an issue to a new status.
226    pub async fn transition_issue(&self, issue_key: &str, transition_id: &str) -> Result<()> {
227        let input = TransitionInput::new(transition_id);
228        self.request_no_response(
229            reqwest::Method::POST,
230            &format!("/issue/{}/transitions", issue_key),
231            Some(&input),
232        )
233        .await
234    }
235
236    /// Get the authenticated user.
237    pub async fn myself(&self) -> Result<User> {
238        self.get("/myself").await
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use crate::auth::BasicAuth;
246
247    #[test]
248    fn test_builder_requires_base_url() {
249        let result = Client::builder()
250            .auth(BasicAuth::new("email@example.com", "api_token"))
251            .build();
252        assert!(result.is_err());
253    }
254
255    #[test]
256    fn test_builder_with_base_url() {
257        let result = Client::builder()
258            .auth(BasicAuth::new("email@example.com", "api_token"))
259            .base_url("https://example.atlassian.net")
260            .build();
261        assert!(result.is_ok());
262    }
263}