Skip to main content

atlassian_cli_api/
lib.rs

1pub mod error;
2pub mod pagination;
3pub mod ratelimit;
4pub mod retry;
5
6use error::{ApiError, Result};
7use ratelimit::RateLimiter;
8use reqwest::{Client, Method, RequestBuilder, StatusCode};
9use retry::{retry_with_backoff, RetryConfig};
10use secrecy::{ExposeSecret, SecretString};
11use serde::de::DeserializeOwned;
12use serde::Serialize;
13use std::fmt;
14use std::time::Duration;
15use tracing::{debug, error, warn};
16use url::Url;
17
18#[derive(Clone)]
19pub enum AuthMethod {
20    Basic {
21        username: String,
22        token: SecretString,
23    },
24    Bearer {
25        token: SecretString,
26    },
27    GenieKey {
28        api_key: SecretString,
29    },
30}
31
32impl fmt::Debug for AuthMethod {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        match self {
35            AuthMethod::Basic { username, .. } => f
36                .debug_struct("Basic")
37                .field("username", username)
38                .field("token", &"[REDACTED]")
39                .finish(),
40            AuthMethod::Bearer { .. } => f
41                .debug_struct("Bearer")
42                .field("token", &"[REDACTED]")
43                .finish(),
44            AuthMethod::GenieKey { .. } => f
45                .debug_struct("GenieKey")
46                .field("api_key", &"[REDACTED]")
47                .finish(),
48        }
49    }
50}
51
52#[derive(Clone)]
53pub struct ApiClient {
54    client: Client,
55    base_url: Url,
56    auth: Option<AuthMethod>,
57    retry_config: RetryConfig,
58    rate_limiter: RateLimiter,
59}
60
61impl ApiClient {
62    pub fn new(base_url: impl AsRef<str>) -> Result<Self> {
63        let url = Url::parse(base_url.as_ref()).map_err(ApiError::InvalidUrl)?;
64
65        // Enforce HTTPS for security (prevent accidental credential leaks over HTTP)
66        // Allow HTTP only for localhost/127.0.0.1 (for testing)
67        if url.scheme() != "https" {
68            let is_localhost = url
69                .host_str()
70                .map(|h| h == "localhost" || h == "127.0.0.1" || h.starts_with("127."))
71                .unwrap_or(false);
72
73            if !is_localhost {
74                return Err(ApiError::InvalidUrl(
75                    url::ParseError::InvalidDomainCharacter,
76                ));
77            }
78        }
79
80        let client = Client::builder()
81            .user_agent(format!("atlassian-cli/{}", env!("CARGO_PKG_VERSION")))
82            .timeout(Duration::from_secs(30))
83            .build()
84            .map_err(ApiError::RequestFailed)?;
85
86        Ok(Self {
87            client,
88            base_url: url,
89            auth: None,
90            retry_config: RetryConfig::default(),
91            rate_limiter: RateLimiter::new(),
92        })
93    }
94
95    /// Safely join a path to the base URL, ensuring scheme and host remain unchanged
96    /// to prevent SSRF attacks.
97    fn safe_join(&self, path: &str) -> Result<Url> {
98        let joined = self
99            .base_url
100            .join(path.strip_prefix('/').unwrap_or(path))
101            .map_err(ApiError::InvalidUrl)?;
102
103        // Validate that scheme and host haven't changed (SSRF protection)
104        if joined.scheme() != self.base_url.scheme() || joined.host() != self.base_url.host() {
105            return Err(ApiError::InvalidUrl(
106                url::ParseError::InvalidDomainCharacter,
107            ));
108        }
109
110        Ok(joined)
111    }
112
113    pub fn with_basic_auth(
114        mut self,
115        username: impl Into<String>,
116        token: impl Into<String>,
117    ) -> Self {
118        self.auth = Some(AuthMethod::Basic {
119            username: username.into(),
120            token: SecretString::from(token.into()),
121        });
122        self
123    }
124
125    pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
126        self.auth = Some(AuthMethod::Bearer {
127            token: SecretString::from(token.into()),
128        });
129        self
130    }
131
132    pub fn with_genie_key(mut self, api_key: impl Into<String>) -> Self {
133        self.auth = Some(AuthMethod::GenieKey {
134            api_key: SecretString::from(api_key.into()),
135        });
136        self
137    }
138
139    pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
140        self.retry_config = config;
141        self
142    }
143
144    pub fn base_url(&self) -> &str {
145        self.base_url.as_str()
146    }
147
148    /// Returns a reference to the underlying HTTP client for raw requests (e.g., multipart uploads).
149    pub fn http_client(&self) -> &Client {
150        &self.client
151    }
152
153    pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
154        self.request(Method::GET, path, Option::<&()>::None).await
155    }
156
157    pub async fn post<T: DeserializeOwned, B: Serialize + ?Sized>(
158        &self,
159        path: &str,
160        body: &B,
161    ) -> Result<T> {
162        self.request(Method::POST, path, Some(body)).await
163    }
164
165    pub async fn put<T: DeserializeOwned, B: Serialize + ?Sized>(
166        &self,
167        path: &str,
168        body: &B,
169    ) -> Result<T> {
170        self.request(Method::PUT, path, Some(body)).await
171    }
172
173    pub async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
174        self.request(Method::DELETE, path, Option::<&()>::None)
175            .await
176    }
177
178    pub async fn delete_with_body<T: DeserializeOwned, B: Serialize + ?Sized>(
179        &self,
180        path: &str,
181        body: &B,
182    ) -> Result<T> {
183        self.request(Method::DELETE, path, Some(body)).await
184    }
185
186    /// Get plain text content from an endpoint.
187    /// Sets Accept: text/plain; charset=utf-8 header.
188    /// Includes retry logic and rate limiting.
189    pub async fn get_text(&self, path: &str) -> Result<String> {
190        if let Some(wait_secs) = self.rate_limiter.check_limit().await {
191            warn!(wait_secs, "Rate limit reached, waiting");
192            tokio::time::sleep(Duration::from_secs(wait_secs)).await;
193        }
194
195        let joined = self.safe_join(path)?;
196
197        debug!(method = "GET", url = %joined, "Sending text request");
198
199        let result = retry_with_backoff(&self.retry_config, || async {
200            let mut req = self.client.request(Method::GET, joined.clone());
201            req = self.apply_auth(req);
202            req = req.header("Accept", "text/plain, */*;q=0.1");
203
204            let response = req.send().await.map_err(ApiError::RequestFailed)?;
205
206            self.rate_limiter.update_from_response(&response).await;
207
208            let status = response.status();
209
210            match status {
211                StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationFailed {
212                    message: "Invalid or expired credentials".to_string(),
213                }),
214                StatusCode::FORBIDDEN => {
215                    let message = response
216                        .text()
217                        .await
218                        .unwrap_or_else(|_| "Access forbidden".to_string());
219                    Err(ApiError::Forbidden { message })
220                }
221                StatusCode::NOT_FOUND => {
222                    let resource = joined.path().to_string();
223                    Err(ApiError::NotFound { resource })
224                }
225                StatusCode::BAD_REQUEST => {
226                    let message = response
227                        .text()
228                        .await
229                        .unwrap_or_else(|_| "Bad request".to_string());
230                    Err(ApiError::BadRequest { message })
231                }
232                StatusCode::NOT_ACCEPTABLE => {
233                    let message = response
234                        .text()
235                        .await
236                        .unwrap_or_else(|_| "Content not acceptable".to_string());
237                    Err(ApiError::ServerError {
238                        status: 406,
239                        message,
240                    })
241                }
242                StatusCode::TOO_MANY_REQUESTS => {
243                    let retry_after = response
244                        .headers()
245                        .get("retry-after")
246                        .and_then(|v| v.to_str().ok())
247                        .and_then(|s| s.parse().ok())
248                        .unwrap_or(60);
249                    Err(ApiError::RateLimitExceeded { retry_after })
250                }
251                status if status.is_server_error() => {
252                    let message = response
253                        .text()
254                        .await
255                        .unwrap_or_else(|_| "Server error".to_string());
256                    Err(ApiError::ServerError {
257                        status: status.as_u16(),
258                        message,
259                    })
260                }
261                status if status.is_success() => response.text().await.map_err(|e| {
262                    error!("Failed to read text response: {}", e);
263                    ApiError::InvalidResponse(e.to_string())
264                }),
265                _ => {
266                    let message = response
267                        .text()
268                        .await
269                        .unwrap_or_else(|_| format!("Unexpected status: {}", status));
270                    Err(ApiError::ServerError {
271                        status: status.as_u16(),
272                        message,
273                    })
274                }
275            }
276        })
277        .await?;
278
279        Ok(result)
280    }
281
282    /// Get binary content from an endpoint.
283    /// Includes retry logic and rate limiting.
284    pub async fn get_bytes(&self, path: &str) -> Result<Vec<u8>> {
285        if let Some(wait_secs) = self.rate_limiter.check_limit().await {
286            warn!(wait_secs, "Rate limit reached, waiting");
287            tokio::time::sleep(Duration::from_secs(wait_secs)).await;
288        }
289
290        let joined = self.safe_join(path)?;
291
292        debug!(method = "GET", url = %joined, "Sending bytes request");
293
294        let result = retry_with_backoff(&self.retry_config, || async {
295            let mut req = self.client.request(Method::GET, joined.clone());
296            req = self.apply_auth(req);
297
298            let response = req.send().await.map_err(ApiError::RequestFailed)?;
299
300            self.rate_limiter.update_from_response(&response).await;
301
302            let status = response.status();
303
304            match status {
305                StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationFailed {
306                    message: "Invalid or expired credentials".to_string(),
307                }),
308                StatusCode::FORBIDDEN => {
309                    let message = response
310                        .text()
311                        .await
312                        .unwrap_or_else(|_| "Access forbidden".to_string());
313                    Err(ApiError::Forbidden { message })
314                }
315                StatusCode::NOT_FOUND => {
316                    let resource = joined.path().to_string();
317                    Err(ApiError::NotFound { resource })
318                }
319                StatusCode::TOO_MANY_REQUESTS => {
320                    let retry_after = response
321                        .headers()
322                        .get("retry-after")
323                        .and_then(|v| v.to_str().ok())
324                        .and_then(|s| s.parse().ok())
325                        .unwrap_or(60);
326                    Err(ApiError::RateLimitExceeded { retry_after })
327                }
328                status if status.is_success() => {
329                    response.bytes().await.map(|b| b.to_vec()).map_err(|e| {
330                        error!("Failed to read bytes response: {}", e);
331                        ApiError::InvalidResponse(e.to_string())
332                    })
333                }
334                _ => {
335                    let message = response
336                        .text()
337                        .await
338                        .unwrap_or_else(|_| format!("Unexpected status: {}", status));
339                    Err(ApiError::ServerError {
340                        status: status.as_u16(),
341                        message,
342                    })
343                }
344            }
345        })
346        .await?;
347
348        Ok(result)
349    }
350
351    pub async fn request<T: DeserializeOwned, B: Serialize + ?Sized>(
352        &self,
353        method: Method,
354        path: &str,
355        body: Option<&B>,
356    ) -> Result<T> {
357        if let Some(wait_secs) = self.rate_limiter.check_limit().await {
358            warn!(wait_secs, "Rate limit reached, waiting");
359            tokio::time::sleep(Duration::from_secs(wait_secs)).await;
360        }
361
362        let joined = self.safe_join(path)?;
363
364        debug!(method = %method, url = %joined, "Sending request");
365
366        let result = retry_with_backoff(&self.retry_config, || async {
367            let mut req = self.client.request(method.clone(), joined.clone());
368            req = self.apply_auth(req);
369
370            if let Some(body) = body {
371                req = req.json(body);
372            }
373
374            let response = req.send().await.map_err(ApiError::RequestFailed)?;
375
376            self.rate_limiter.update_from_response(&response).await;
377
378            let status = response.status();
379
380            match status {
381                StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationFailed {
382                    message: "Invalid or expired credentials".to_string(),
383                }),
384                StatusCode::FORBIDDEN => {
385                    let message = response
386                        .text()
387                        .await
388                        .unwrap_or_else(|_| "Access forbidden".to_string());
389                    Err(ApiError::Forbidden { message })
390                }
391                StatusCode::NOT_FOUND => {
392                    let resource = joined.path().to_string();
393                    Err(ApiError::NotFound { resource })
394                }
395                StatusCode::BAD_REQUEST => {
396                    let message = response
397                        .text()
398                        .await
399                        .unwrap_or_else(|_| "Bad request".to_string());
400                    Err(ApiError::BadRequest { message })
401                }
402                StatusCode::TOO_MANY_REQUESTS => {
403                    let retry_after = response
404                        .headers()
405                        .get("retry-after")
406                        .and_then(|v| v.to_str().ok())
407                        .and_then(|s| s.parse().ok())
408                        .unwrap_or(60);
409                    Err(ApiError::RateLimitExceeded { retry_after })
410                }
411                status if status.is_server_error() => {
412                    let message = response
413                        .text()
414                        .await
415                        .unwrap_or_else(|_| "Server error".to_string());
416                    Err(ApiError::ServerError {
417                        status: status.as_u16(),
418                        message,
419                    })
420                }
421                status if status.is_success() => response.json::<T>().await.map_err(|e| {
422                    error!("Failed to parse JSON response: {}", e);
423                    ApiError::InvalidResponse(e.to_string())
424                }),
425                _ => {
426                    let message = response
427                        .text()
428                        .await
429                        .unwrap_or_else(|_| format!("Unexpected status: {}", status));
430                    Err(ApiError::ServerError {
431                        status: status.as_u16(),
432                        message,
433                    })
434                }
435            }
436        })
437        .await?;
438
439        Ok(result)
440    }
441
442    pub fn apply_auth(&self, request: RequestBuilder) -> RequestBuilder {
443        match &self.auth {
444            Some(AuthMethod::Basic { username, token }) => {
445                request.basic_auth(username, Some(token.expose_secret()))
446            }
447            Some(AuthMethod::Bearer { token }) => request.bearer_auth(token.expose_secret()),
448            Some(AuthMethod::GenieKey { api_key }) => request.header(
449                "Authorization",
450                format!("GenieKey {}", api_key.expose_secret()),
451            ),
452            None => request,
453        }
454    }
455
456    pub fn rate_limiter(&self) -> &RateLimiter {
457        &self.rate_limiter
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use wiremock::matchers::{method, path};
465    use wiremock::{Mock, MockServer, ResponseTemplate};
466
467    #[tokio::test]
468    async fn test_403_returns_forbidden() {
469        let server = MockServer::start().await;
470        Mock::given(method("GET"))
471            .and(path("test"))
472            .respond_with(ResponseTemplate::new(403).set_body_string("You do not have access"))
473            .mount(&server)
474            .await;
475
476        let client = ApiClient::new(server.uri()).unwrap();
477        let result: error::Result<serde_json::Value> = client.get("/test").await;
478
479        match result {
480            Err(ApiError::Forbidden { message }) => {
481                assert!(message.contains("You do not have access"));
482            }
483            other => panic!("Expected Forbidden, got: {:?}", other),
484        }
485    }
486
487    #[tokio::test]
488    async fn test_401_returns_authentication_failed() {
489        let server = MockServer::start().await;
490        Mock::given(method("GET"))
491            .and(path("test"))
492            .respond_with(ResponseTemplate::new(401))
493            .mount(&server)
494            .await;
495
496        let client = ApiClient::new(server.uri()).unwrap();
497        let result: error::Result<serde_json::Value> = client.get("/test").await;
498
499        match result {
500            Err(ApiError::AuthenticationFailed { .. }) => {}
501            other => panic!("Expected AuthenticationFailed, got: {:?}", other),
502        }
503    }
504
505    #[tokio::test]
506    async fn test_403_get_text_returns_forbidden() {
507        let server = MockServer::start().await;
508        Mock::given(method("GET"))
509            .and(path("text-endpoint"))
510            .respond_with(ResponseTemplate::new(403).set_body_string("Forbidden resource"))
511            .mount(&server)
512            .await;
513
514        let client = ApiClient::new(server.uri()).unwrap();
515        let result = client.get_text("/text-endpoint").await;
516
517        match result {
518            Err(ApiError::Forbidden { message }) => {
519                assert!(message.contains("Forbidden resource"));
520            }
521            other => panic!("Expected Forbidden, got: {:?}", other),
522        }
523    }
524
525    #[tokio::test]
526    async fn test_403_get_bytes_returns_forbidden() {
527        let server = MockServer::start().await;
528        Mock::given(method("GET"))
529            .and(path("bytes-endpoint"))
530            .respond_with(ResponseTemplate::new(403).set_body_string("Access denied"))
531            .mount(&server)
532            .await;
533
534        let client = ApiClient::new(server.uri()).unwrap();
535        let result = client.get_bytes("/bytes-endpoint").await;
536
537        match result {
538            Err(ApiError::Forbidden { message }) => {
539                assert!(message.contains("Access denied"));
540            }
541            other => panic!("Expected Forbidden, got: {:?}", other),
542        }
543    }
544}