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    /// DELETE that expects 204 No Content (no response body).
187    pub async fn delete_no_content(&self, path: &str) -> Result<()> {
188        if let Some(wait_secs) = self.rate_limiter.check_limit().await {
189            warn!(wait_secs, "Rate limit reached, waiting");
190            tokio::time::sleep(Duration::from_secs(wait_secs)).await;
191        }
192
193        let joined = self.safe_join(path)?;
194
195        debug!(method = "DELETE", url = %joined, "Sending delete (no content) request");
196
197        retry_with_backoff(&self.retry_config, || async {
198            let mut req = self.client.request(Method::DELETE, joined.clone());
199            req = self.apply_auth(req);
200
201            let response = req.send().await.map_err(ApiError::RequestFailed)?;
202
203            self.rate_limiter.update_from_response(&response).await;
204
205            let status = response.status();
206
207            match status {
208                StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationFailed {
209                    message: "Invalid or expired credentials".to_string(),
210                }),
211                StatusCode::FORBIDDEN => {
212                    let message = response
213                        .text()
214                        .await
215                        .unwrap_or_else(|_| "Access forbidden".to_string());
216                    Err(ApiError::Forbidden { message })
217                }
218                StatusCode::NOT_FOUND => {
219                    let resource = joined.path().to_string();
220                    Err(ApiError::NotFound { resource })
221                }
222                StatusCode::BAD_REQUEST => {
223                    let message = response
224                        .text()
225                        .await
226                        .unwrap_or_else(|_| "Bad request".to_string());
227                    Err(ApiError::BadRequest { message })
228                }
229                StatusCode::TOO_MANY_REQUESTS => {
230                    let retry_after = response
231                        .headers()
232                        .get("retry-after")
233                        .and_then(|v| v.to_str().ok())
234                        .and_then(|s| s.parse().ok())
235                        .unwrap_or(60);
236                    Err(ApiError::RateLimitExceeded { retry_after })
237                }
238                status if status.is_server_error() => {
239                    let message = response
240                        .text()
241                        .await
242                        .unwrap_or_else(|_| "Server error".to_string());
243                    Err(ApiError::ServerError {
244                        status: status.as_u16(),
245                        message,
246                    })
247                }
248                status if status.is_success() => Ok(()),
249                _ => {
250                    let message = response
251                        .text()
252                        .await
253                        .unwrap_or_else(|_| format!("Unexpected status: {}", status));
254                    Err(ApiError::ServerError {
255                        status: status.as_u16(),
256                        message,
257                    })
258                }
259            }
260        })
261        .await
262    }
263
264    /// Get plain text content from an endpoint.
265    /// Sets Accept: text/plain; charset=utf-8 header.
266    /// Includes retry logic and rate limiting.
267    pub async fn get_text(&self, path: &str) -> Result<String> {
268        if let Some(wait_secs) = self.rate_limiter.check_limit().await {
269            warn!(wait_secs, "Rate limit reached, waiting");
270            tokio::time::sleep(Duration::from_secs(wait_secs)).await;
271        }
272
273        let joined = self.safe_join(path)?;
274
275        debug!(method = "GET", url = %joined, "Sending text request");
276
277        let result = retry_with_backoff(&self.retry_config, || async {
278            let mut req = self.client.request(Method::GET, joined.clone());
279            req = self.apply_auth(req);
280            req = req.header("Accept", "text/plain, */*;q=0.1");
281
282            let response = req.send().await.map_err(ApiError::RequestFailed)?;
283
284            self.rate_limiter.update_from_response(&response).await;
285
286            let status = response.status();
287
288            match status {
289                StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationFailed {
290                    message: "Invalid or expired credentials".to_string(),
291                }),
292                StatusCode::FORBIDDEN => {
293                    let message = response
294                        .text()
295                        .await
296                        .unwrap_or_else(|_| "Access forbidden".to_string());
297                    Err(ApiError::Forbidden { message })
298                }
299                StatusCode::NOT_FOUND => {
300                    let resource = joined.path().to_string();
301                    Err(ApiError::NotFound { resource })
302                }
303                StatusCode::BAD_REQUEST => {
304                    let message = response
305                        .text()
306                        .await
307                        .unwrap_or_else(|_| "Bad request".to_string());
308                    Err(ApiError::BadRequest { message })
309                }
310                StatusCode::NOT_ACCEPTABLE => {
311                    let message = response
312                        .text()
313                        .await
314                        .unwrap_or_else(|_| "Content not acceptable".to_string());
315                    Err(ApiError::ServerError {
316                        status: 406,
317                        message,
318                    })
319                }
320                StatusCode::TOO_MANY_REQUESTS => {
321                    let retry_after = response
322                        .headers()
323                        .get("retry-after")
324                        .and_then(|v| v.to_str().ok())
325                        .and_then(|s| s.parse().ok())
326                        .unwrap_or(60);
327                    Err(ApiError::RateLimitExceeded { retry_after })
328                }
329                status if status.is_server_error() => {
330                    let message = response
331                        .text()
332                        .await
333                        .unwrap_or_else(|_| "Server error".to_string());
334                    Err(ApiError::ServerError {
335                        status: status.as_u16(),
336                        message,
337                    })
338                }
339                status if status.is_success() => response.text().await.map_err(|e| {
340                    error!("Failed to read text response: {}", e);
341                    ApiError::InvalidResponse(e.to_string())
342                }),
343                _ => {
344                    let message = response
345                        .text()
346                        .await
347                        .unwrap_or_else(|_| format!("Unexpected status: {}", status));
348                    Err(ApiError::ServerError {
349                        status: status.as_u16(),
350                        message,
351                    })
352                }
353            }
354        })
355        .await?;
356
357        Ok(result)
358    }
359
360    /// Get binary content from an endpoint.
361    /// Includes retry logic and rate limiting.
362    pub async fn get_bytes(&self, path: &str) -> Result<Vec<u8>> {
363        if let Some(wait_secs) = self.rate_limiter.check_limit().await {
364            warn!(wait_secs, "Rate limit reached, waiting");
365            tokio::time::sleep(Duration::from_secs(wait_secs)).await;
366        }
367
368        let joined = self.safe_join(path)?;
369
370        debug!(method = "GET", url = %joined, "Sending bytes request");
371
372        let result = retry_with_backoff(&self.retry_config, || async {
373            let mut req = self.client.request(Method::GET, joined.clone());
374            req = self.apply_auth(req);
375
376            let response = req.send().await.map_err(ApiError::RequestFailed)?;
377
378            self.rate_limiter.update_from_response(&response).await;
379
380            let status = response.status();
381
382            match status {
383                StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationFailed {
384                    message: "Invalid or expired credentials".to_string(),
385                }),
386                StatusCode::FORBIDDEN => {
387                    let message = response
388                        .text()
389                        .await
390                        .unwrap_or_else(|_| "Access forbidden".to_string());
391                    Err(ApiError::Forbidden { message })
392                }
393                StatusCode::NOT_FOUND => {
394                    let resource = joined.path().to_string();
395                    Err(ApiError::NotFound { resource })
396                }
397                StatusCode::TOO_MANY_REQUESTS => {
398                    let retry_after = response
399                        .headers()
400                        .get("retry-after")
401                        .and_then(|v| v.to_str().ok())
402                        .and_then(|s| s.parse().ok())
403                        .unwrap_or(60);
404                    Err(ApiError::RateLimitExceeded { retry_after })
405                }
406                status if status.is_success() => {
407                    response.bytes().await.map(|b| b.to_vec()).map_err(|e| {
408                        error!("Failed to read bytes response: {}", e);
409                        ApiError::InvalidResponse(e.to_string())
410                    })
411                }
412                _ => {
413                    let message = response
414                        .text()
415                        .await
416                        .unwrap_or_else(|_| format!("Unexpected status: {}", status));
417                    Err(ApiError::ServerError {
418                        status: status.as_u16(),
419                        message,
420                    })
421                }
422            }
423        })
424        .await?;
425
426        Ok(result)
427    }
428
429    pub async fn request<T: DeserializeOwned, B: Serialize + ?Sized>(
430        &self,
431        method: Method,
432        path: &str,
433        body: Option<&B>,
434    ) -> Result<T> {
435        if let Some(wait_secs) = self.rate_limiter.check_limit().await {
436            warn!(wait_secs, "Rate limit reached, waiting");
437            tokio::time::sleep(Duration::from_secs(wait_secs)).await;
438        }
439
440        let joined = self.safe_join(path)?;
441
442        debug!(method = %method, url = %joined, "Sending request");
443
444        let result = retry_with_backoff(&self.retry_config, || async {
445            let mut req = self.client.request(method.clone(), joined.clone());
446            req = self.apply_auth(req);
447
448            if let Some(body) = body {
449                req = req.json(body);
450            }
451
452            let response = req.send().await.map_err(ApiError::RequestFailed)?;
453
454            self.rate_limiter.update_from_response(&response).await;
455
456            let status = response.status();
457
458            match status {
459                StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationFailed {
460                    message: "Invalid or expired credentials".to_string(),
461                }),
462                StatusCode::FORBIDDEN => {
463                    let message = response
464                        .text()
465                        .await
466                        .unwrap_or_else(|_| "Access forbidden".to_string());
467                    Err(ApiError::Forbidden { message })
468                }
469                StatusCode::NOT_FOUND => {
470                    let resource = joined.path().to_string();
471                    Err(ApiError::NotFound { resource })
472                }
473                StatusCode::BAD_REQUEST => {
474                    let message = response
475                        .text()
476                        .await
477                        .unwrap_or_else(|_| "Bad request".to_string());
478                    Err(ApiError::BadRequest { message })
479                }
480                StatusCode::TOO_MANY_REQUESTS => {
481                    let retry_after = response
482                        .headers()
483                        .get("retry-after")
484                        .and_then(|v| v.to_str().ok())
485                        .and_then(|s| s.parse().ok())
486                        .unwrap_or(60);
487                    Err(ApiError::RateLimitExceeded { retry_after })
488                }
489                status if status.is_server_error() => {
490                    let message = response
491                        .text()
492                        .await
493                        .unwrap_or_else(|_| "Server error".to_string());
494                    Err(ApiError::ServerError {
495                        status: status.as_u16(),
496                        message,
497                    })
498                }
499                status if status.is_success() => response.json::<T>().await.map_err(|e| {
500                    error!("Failed to parse JSON response: {}", e);
501                    ApiError::InvalidResponse(e.to_string())
502                }),
503                _ => {
504                    let message = response
505                        .text()
506                        .await
507                        .unwrap_or_else(|_| format!("Unexpected status: {}", status));
508                    Err(ApiError::ServerError {
509                        status: status.as_u16(),
510                        message,
511                    })
512                }
513            }
514        })
515        .await?;
516
517        Ok(result)
518    }
519
520    pub fn apply_auth(&self, request: RequestBuilder) -> RequestBuilder {
521        match &self.auth {
522            Some(AuthMethod::Basic { username, token }) => {
523                request.basic_auth(username, Some(token.expose_secret()))
524            }
525            Some(AuthMethod::Bearer { token }) => request.bearer_auth(token.expose_secret()),
526            Some(AuthMethod::GenieKey { api_key }) => request.header(
527                "Authorization",
528                format!("GenieKey {}", api_key.expose_secret()),
529            ),
530            None => request,
531        }
532    }
533
534    pub fn rate_limiter(&self) -> &RateLimiter {
535        &self.rate_limiter
536    }
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542    use wiremock::matchers::{method, path};
543    use wiremock::{Mock, MockServer, ResponseTemplate};
544
545    #[tokio::test]
546    async fn test_403_returns_forbidden() {
547        let server = MockServer::start().await;
548        Mock::given(method("GET"))
549            .and(path("test"))
550            .respond_with(ResponseTemplate::new(403).set_body_string("You do not have access"))
551            .mount(&server)
552            .await;
553
554        let client = ApiClient::new(server.uri()).unwrap();
555        let result: error::Result<serde_json::Value> = client.get("/test").await;
556
557        match result {
558            Err(ApiError::Forbidden { message }) => {
559                assert!(message.contains("You do not have access"));
560            }
561            other => panic!("Expected Forbidden, got: {:?}", other),
562        }
563    }
564
565    #[tokio::test]
566    async fn test_401_returns_authentication_failed() {
567        let server = MockServer::start().await;
568        Mock::given(method("GET"))
569            .and(path("test"))
570            .respond_with(ResponseTemplate::new(401))
571            .mount(&server)
572            .await;
573
574        let client = ApiClient::new(server.uri()).unwrap();
575        let result: error::Result<serde_json::Value> = client.get("/test").await;
576
577        match result {
578            Err(ApiError::AuthenticationFailed { .. }) => {}
579            other => panic!("Expected AuthenticationFailed, got: {:?}", other),
580        }
581    }
582
583    #[tokio::test]
584    async fn test_403_get_text_returns_forbidden() {
585        let server = MockServer::start().await;
586        Mock::given(method("GET"))
587            .and(path("text-endpoint"))
588            .respond_with(ResponseTemplate::new(403).set_body_string("Forbidden resource"))
589            .mount(&server)
590            .await;
591
592        let client = ApiClient::new(server.uri()).unwrap();
593        let result = client.get_text("/text-endpoint").await;
594
595        match result {
596            Err(ApiError::Forbidden { message }) => {
597                assert!(message.contains("Forbidden resource"));
598            }
599            other => panic!("Expected Forbidden, got: {:?}", other),
600        }
601    }
602
603    #[tokio::test]
604    async fn test_403_get_bytes_returns_forbidden() {
605        let server = MockServer::start().await;
606        Mock::given(method("GET"))
607            .and(path("bytes-endpoint"))
608            .respond_with(ResponseTemplate::new(403).set_body_string("Access denied"))
609            .mount(&server)
610            .await;
611
612        let client = ApiClient::new(server.uri()).unwrap();
613        let result = client.get_bytes("/bytes-endpoint").await;
614
615        match result {
616            Err(ApiError::Forbidden { message }) => {
617                assert!(message.contains("Access denied"));
618            }
619            other => panic!("Expected Forbidden, got: {:?}", other),
620        }
621    }
622}