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 serde::de::DeserializeOwned;
11use serde::Serialize;
12use std::time::Duration;
13use tracing::{debug, error, warn};
14use url::Url;
15
16#[derive(Clone, Debug)]
17pub enum AuthMethod {
18    Basic { username: String, token: String },
19    Bearer { token: String },
20}
21
22#[derive(Clone)]
23pub struct ApiClient {
24    client: Client,
25    base_url: Url,
26    auth: Option<AuthMethod>,
27    retry_config: RetryConfig,
28    rate_limiter: RateLimiter,
29}
30
31impl ApiClient {
32    pub fn new(base_url: impl AsRef<str>) -> Result<Self> {
33        let url = Url::parse(base_url.as_ref()).map_err(ApiError::InvalidUrl)?;
34
35        let client = Client::builder()
36            .user_agent(format!("atlassian-cli/{}", env!("CARGO_PKG_VERSION")))
37            .timeout(Duration::from_secs(30))
38            .build()
39            .map_err(ApiError::RequestFailed)?;
40
41        Ok(Self {
42            client,
43            base_url: url,
44            auth: None,
45            retry_config: RetryConfig::default(),
46            rate_limiter: RateLimiter::new(),
47        })
48    }
49
50    pub fn with_basic_auth(
51        mut self,
52        username: impl Into<String>,
53        token: impl Into<String>,
54    ) -> Self {
55        self.auth = Some(AuthMethod::Basic {
56            username: username.into(),
57            token: token.into(),
58        });
59        self
60    }
61
62    pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
63        self.auth = Some(AuthMethod::Bearer {
64            token: token.into(),
65        });
66        self
67    }
68
69    pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
70        self.retry_config = config;
71        self
72    }
73
74    pub fn base_url(&self) -> &str {
75        self.base_url.as_str()
76    }
77
78    pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
79        self.request(Method::GET, path, Option::<&()>::None).await
80    }
81
82    pub async fn post<T: DeserializeOwned, B: Serialize + ?Sized>(
83        &self,
84        path: &str,
85        body: &B,
86    ) -> Result<T> {
87        self.request(Method::POST, path, Some(body)).await
88    }
89
90    pub async fn put<T: DeserializeOwned, B: Serialize + ?Sized>(
91        &self,
92        path: &str,
93        body: &B,
94    ) -> Result<T> {
95        self.request(Method::PUT, path, Some(body)).await
96    }
97
98    pub async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
99        self.request(Method::DELETE, path, Option::<&()>::None)
100            .await
101    }
102
103    pub async fn request<T: DeserializeOwned, B: Serialize + ?Sized>(
104        &self,
105        method: Method,
106        path: &str,
107        body: Option<&B>,
108    ) -> Result<T> {
109        if let Some(wait_secs) = self.rate_limiter.check_limit().await {
110            warn!(wait_secs, "Rate limit reached, waiting");
111            tokio::time::sleep(Duration::from_secs(wait_secs)).await;
112        }
113
114        let url = self.base_url.clone();
115        let joined = url
116            .join(path.strip_prefix('/').unwrap_or(path))
117            .map_err(ApiError::InvalidUrl)?;
118
119        debug!(method = %method, url = %joined, "Sending request");
120
121        let result = retry_with_backoff(&self.retry_config, || async {
122            let mut req = self.client.request(method.clone(), joined.clone());
123            req = self.apply_auth(req);
124
125            if let Some(body) = body {
126                req = req.json(body);
127            }
128
129            let response = req.send().await.map_err(ApiError::RequestFailed)?;
130
131            self.rate_limiter.update_from_response(&response).await;
132
133            let status = response.status();
134
135            match status {
136                StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationFailed {
137                    message: "Invalid or expired credentials".to_string(),
138                }),
139                StatusCode::NOT_FOUND => {
140                    let resource = joined.path().to_string();
141                    Err(ApiError::NotFound { resource })
142                }
143                StatusCode::BAD_REQUEST => {
144                    let message = response
145                        .text()
146                        .await
147                        .unwrap_or_else(|_| "Bad request".to_string());
148                    Err(ApiError::BadRequest { message })
149                }
150                StatusCode::TOO_MANY_REQUESTS => {
151                    let retry_after = response
152                        .headers()
153                        .get("retry-after")
154                        .and_then(|v| v.to_str().ok())
155                        .and_then(|s| s.parse().ok())
156                        .unwrap_or(60);
157                    Err(ApiError::RateLimitExceeded { retry_after })
158                }
159                status if status.is_server_error() => {
160                    let message = response
161                        .text()
162                        .await
163                        .unwrap_or_else(|_| "Server error".to_string());
164                    Err(ApiError::ServerError {
165                        status: status.as_u16(),
166                        message,
167                    })
168                }
169                status if status.is_success() => response.json::<T>().await.map_err(|e| {
170                    error!("Failed to parse JSON response: {}", e);
171                    ApiError::InvalidResponse(e.to_string())
172                }),
173                _ => {
174                    let message = response
175                        .text()
176                        .await
177                        .unwrap_or_else(|_| format!("Unexpected status: {}", status));
178                    Err(ApiError::ServerError {
179                        status: status.as_u16(),
180                        message,
181                    })
182                }
183            }
184        })
185        .await?;
186
187        Ok(result)
188    }
189
190    pub fn apply_auth(&self, request: RequestBuilder) -> RequestBuilder {
191        match &self.auth {
192            Some(AuthMethod::Basic { username, token }) => {
193                request.basic_auth(username, Some(token))
194            }
195            Some(AuthMethod::Bearer { token }) => request.bearer_auth(token),
196            None => request,
197        }
198    }
199
200    pub fn rate_limiter(&self) -> &RateLimiter {
201        &self.rate_limiter
202    }
203}