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