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