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 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 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 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 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 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 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}