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