1pub mod error;
2pub mod pagination;
3pub mod ratelimit;
4pub mod retry;
5
6use backoff::backoff::Backoff;
7use error::{ApiError, Result};
8use ratelimit::RateLimiter;
9use reqwest::header::HeaderMap;
10use reqwest::{Client, Method, RequestBuilder, StatusCode};
11use retry::{retry_with_backoff, RetryConfig};
12use secrecy::{ExposeSecret, SecretString};
13use serde::de::DeserializeOwned;
14use serde::Serialize;
15use std::fmt;
16use std::time::Duration;
17use tracing::{debug, error, warn};
18use url::Url;
19
20#[derive(Clone)]
21pub enum AuthMethod {
22 Basic {
23 username: String,
24 token: SecretString,
25 },
26 Bearer {
27 token: SecretString,
28 },
29 GenieKey {
30 api_key: SecretString,
31 },
32}
33
34impl fmt::Debug for AuthMethod {
35 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36 match self {
37 AuthMethod::Basic { username, .. } => f
38 .debug_struct("Basic")
39 .field("username", username)
40 .field("token", &"[REDACTED]")
41 .finish(),
42 AuthMethod::Bearer { .. } => f
43 .debug_struct("Bearer")
44 .field("token", &"[REDACTED]")
45 .finish(),
46 AuthMethod::GenieKey { .. } => f
47 .debug_struct("GenieKey")
48 .field("api_key", &"[REDACTED]")
49 .finish(),
50 }
51 }
52}
53
54fn same_origin(a: &Url, b: &Url) -> bool {
60 a.scheme() == b.scheme()
61 && a.host() == b.host()
62 && a.port_or_known_default() == b.port_or_known_default()
63}
64
65fn retry_after(response: &reqwest::Response) -> Option<Duration> {
68 response
69 .headers()
70 .get(reqwest::header::RETRY_AFTER)?
71 .to_str()
72 .ok()?
73 .trim()
74 .parse::<u64>()
75 .ok()
76 .map(Duration::from_secs)
77}
78
79pub struct RawRequest<'a> {
81 pub method: Method,
82 pub path: &'a str,
84 pub headers: HeaderMap,
85 pub body: Option<&'a [u8]>,
86 pub timeout: Option<Duration>,
88}
89
90#[derive(Debug, Clone)]
92pub struct RawResponse {
93 pub status: u16,
94 pub headers: Vec<(String, String)>,
95 pub body: Vec<u8>,
96}
97
98impl RawResponse {
99 pub fn is_success(&self) -> bool {
100 (200..300).contains(&self.status)
101 }
102
103 pub fn header(&self, name: &str) -> Option<&str> {
105 self.headers
106 .iter()
107 .find(|(key, _)| key.eq_ignore_ascii_case(name))
108 .map(|(_, value)| value.as_str())
109 }
110}
111
112#[derive(Clone)]
113pub struct ApiClient {
114 client: Client,
115 raw_client: Client,
117 base_url: Url,
118 auth: Option<AuthMethod>,
119 retry_config: RetryConfig,
120 rate_limiter: RateLimiter,
121}
122
123impl ApiClient {
124 pub fn new(base_url: impl AsRef<str>) -> Result<Self> {
125 let url = Url::parse(base_url.as_ref()).map_err(ApiError::InvalidUrl)?;
126
127 if url.scheme() != "https" {
130 let is_localhost = url
131 .host_str()
132 .map(|h| h == "localhost" || h == "127.0.0.1" || h.starts_with("127."))
133 .unwrap_or(false);
134
135 if !is_localhost {
136 return Err(ApiError::InvalidUrl(
137 url::ParseError::InvalidDomainCharacter,
138 ));
139 }
140 }
141
142 let client = Client::builder()
143 .user_agent(format!("atlassian-cli/{}", env!("CARGO_PKG_VERSION")))
144 .timeout(Duration::from_secs(30))
145 .build()
146 .map_err(ApiError::RequestFailed)?;
147
148 let origin = url.clone();
158 let raw_client = Client::builder()
159 .user_agent(format!("atlassian-cli/{}", env!("CARGO_PKG_VERSION")))
160 .timeout(Duration::from_secs(30))
161 .redirect(reqwest::redirect::Policy::custom(move |attempt| {
162 if attempt.previous().len() >= 10 {
163 attempt.error("too many redirects")
164 } else if same_origin(attempt.url(), &origin) {
165 attempt.follow()
166 } else {
167 attempt.stop()
168 }
169 }))
170 .build()
171 .map_err(ApiError::RequestFailed)?;
172
173 Ok(Self {
174 client,
175 raw_client,
176 base_url: url,
177 auth: None,
178 retry_config: RetryConfig::default(),
179 rate_limiter: RateLimiter::new(),
180 })
181 }
182
183 fn safe_join(&self, path: &str) -> Result<Url> {
186 let joined = self
187 .base_url
188 .join(path.strip_prefix('/').unwrap_or(path))
189 .map_err(ApiError::InvalidUrl)?;
190
191 if !same_origin(&joined, &self.base_url) {
192 return Err(ApiError::InvalidUrl(
193 url::ParseError::InvalidDomainCharacter,
194 ));
195 }
196
197 Ok(joined)
198 }
199
200 pub fn with_basic_auth(
201 mut self,
202 username: impl Into<String>,
203 token: impl Into<String>,
204 ) -> Self {
205 self.auth = Some(AuthMethod::Basic {
206 username: username.into(),
207 token: SecretString::from(token.into()),
208 });
209 self
210 }
211
212 pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
213 self.auth = Some(AuthMethod::Bearer {
214 token: SecretString::from(token.into()),
215 });
216 self
217 }
218
219 pub fn with_genie_key(mut self, api_key: impl Into<String>) -> Self {
220 self.auth = Some(AuthMethod::GenieKey {
221 api_key: SecretString::from(api_key.into()),
222 });
223 self
224 }
225
226 pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
227 self.retry_config = config;
228 self
229 }
230
231 pub fn base_url(&self) -> &str {
232 self.base_url.as_str()
233 }
234
235 pub fn http_client(&self) -> &Client {
237 &self.client
238 }
239
240 pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
241 self.request(Method::GET, path, Option::<&()>::None).await
242 }
243
244 pub async fn post<T: DeserializeOwned, B: Serialize + ?Sized>(
245 &self,
246 path: &str,
247 body: &B,
248 ) -> Result<T> {
249 self.request(Method::POST, path, Some(body)).await
250 }
251
252 pub async fn put<T: DeserializeOwned, B: Serialize + ?Sized>(
253 &self,
254 path: &str,
255 body: &B,
256 ) -> Result<T> {
257 self.request(Method::PUT, path, Some(body)).await
258 }
259
260 pub async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
261 self.request(Method::DELETE, path, Option::<&()>::None)
262 .await
263 }
264
265 pub async fn delete_with_body<T: DeserializeOwned, B: Serialize + ?Sized>(
266 &self,
267 path: &str,
268 body: &B,
269 ) -> Result<T> {
270 self.request(Method::DELETE, path, Some(body)).await
271 }
272
273 pub async fn delete_no_content(&self, path: &str) -> Result<()> {
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 = "DELETE", url = %joined, "Sending delete (no content) request");
283
284 retry_with_backoff(&self.retry_config, || async {
285 let mut req = self.client.request(Method::DELETE, joined.clone());
286 req = self.apply_auth(req);
287
288 let response = req.send().await.map_err(ApiError::RequestFailed)?;
289
290 self.rate_limiter.update_from_response(&response).await;
291
292 let status = response.status();
293
294 match status {
295 StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationFailed {
296 message: "Invalid or expired credentials".to_string(),
297 }),
298 StatusCode::FORBIDDEN => {
299 let message = response
300 .text()
301 .await
302 .unwrap_or_else(|_| "Access forbidden".to_string());
303 Err(ApiError::Forbidden { message })
304 }
305 StatusCode::NOT_FOUND => {
306 let resource = joined.path().to_string();
307 Err(ApiError::NotFound { resource })
308 }
309 StatusCode::BAD_REQUEST => {
310 let message = response
311 .text()
312 .await
313 .unwrap_or_else(|_| "Bad request".to_string());
314 Err(ApiError::BadRequest { message })
315 }
316 StatusCode::GONE => {
317 let message = response
318 .text()
319 .await
320 .unwrap_or_else(|_| "API endpoint has been removed".to_string());
321 Err(ApiError::EndpointGone { message })
322 }
323 StatusCode::TOO_MANY_REQUESTS => {
324 let retry_after = response
325 .headers()
326 .get("retry-after")
327 .and_then(|v| v.to_str().ok())
328 .and_then(|s| s.parse().ok())
329 .unwrap_or(60);
330 Err(ApiError::RateLimitExceeded { retry_after })
331 }
332 status if status.is_server_error() => {
333 let message = response
334 .text()
335 .await
336 .unwrap_or_else(|_| "Server error".to_string());
337 Err(ApiError::ServerError {
338 status: status.as_u16(),
339 message,
340 })
341 }
342 status if status.is_success() => Ok(()),
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
358 pub async fn get_text(&self, path: &str) -> Result<String> {
362 if let Some(wait_secs) = self.rate_limiter.check_limit().await {
363 warn!(wait_secs, "Rate limit reached, waiting");
364 tokio::time::sleep(Duration::from_secs(wait_secs)).await;
365 }
366
367 let joined = self.safe_join(path)?;
368
369 debug!(method = "GET", url = %joined, "Sending text request");
370
371 let result = retry_with_backoff(&self.retry_config, || async {
372 let mut req = self.client.request(Method::GET, joined.clone());
373 req = self.apply_auth(req);
374 req = req.header("Accept", "text/plain, */*;q=0.1");
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::BAD_REQUEST => {
398 let message = response
399 .text()
400 .await
401 .unwrap_or_else(|_| "Bad request".to_string());
402 Err(ApiError::BadRequest { message })
403 }
404 StatusCode::NOT_ACCEPTABLE => {
405 let message = response
406 .text()
407 .await
408 .unwrap_or_else(|_| "Content not acceptable".to_string());
409 Err(ApiError::ServerError {
410 status: 406,
411 message,
412 })
413 }
414 StatusCode::GONE => {
415 let message = response
416 .text()
417 .await
418 .unwrap_or_else(|_| "API endpoint has been removed".to_string());
419 Err(ApiError::EndpointGone { message })
420 }
421 StatusCode::TOO_MANY_REQUESTS => {
422 let retry_after = response
423 .headers()
424 .get("retry-after")
425 .and_then(|v| v.to_str().ok())
426 .and_then(|s| s.parse().ok())
427 .unwrap_or(60);
428 Err(ApiError::RateLimitExceeded { retry_after })
429 }
430 status if status.is_server_error() => {
431 let message = response
432 .text()
433 .await
434 .unwrap_or_else(|_| "Server error".to_string());
435 Err(ApiError::ServerError {
436 status: status.as_u16(),
437 message,
438 })
439 }
440 status if status.is_success() => response.text().await.map_err(|e| {
441 error!("Failed to read text response: {}", e);
442 ApiError::InvalidResponse(e.to_string())
443 }),
444 _ => {
445 let message = response
446 .text()
447 .await
448 .unwrap_or_else(|_| format!("Unexpected status: {}", status));
449 Err(ApiError::ServerError {
450 status: status.as_u16(),
451 message,
452 })
453 }
454 }
455 })
456 .await?;
457
458 Ok(result)
459 }
460
461 pub fn resolve_url(&self, path: &str) -> Result<Url> {
465 self.safe_join(path)
466 }
467
468 pub async fn request_raw(&self, req: RawRequest<'_>) -> Result<RawResponse> {
479 if let Some(wait_secs) = self.rate_limiter.check_limit().await {
480 warn!(wait_secs, "Rate limit reached, waiting");
481 tokio::time::sleep(Duration::from_secs(wait_secs)).await;
482 }
483
484 let joined = self.safe_join(req.path)?;
485 debug!(method = %req.method, url = %joined, "Sending raw request");
486
487 let idempotent = matches!(
488 req.method,
489 Method::GET | Method::HEAD | Method::PUT | Method::DELETE | Method::OPTIONS
490 );
491 let mut backoff = self.retry_config.backoff();
495 let mut attempts = 0usize;
496
497 loop {
498 attempts += 1;
499
500 let mut builder = self.raw_client.request(req.method.clone(), joined.clone());
501 builder = self.apply_auth(builder);
502 builder = builder.headers(req.headers.clone());
503 if let Some(body) = req.body {
504 builder = builder.body(body.to_vec());
505 }
506 if let Some(timeout) = req.timeout {
507 builder = builder.timeout(timeout);
508 }
509
510 let response = builder.send().await.map_err(ApiError::RequestFailed)?;
511 self.rate_limiter.update_from_response(&response).await;
512 let status = response.status();
513
514 let retryable = status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error();
515 if idempotent && retryable && attempts < self.retry_config.max_retries {
516 if let Some(wait) = backoff.next_backoff() {
517 let wait = retry_after(&response).unwrap_or(wait);
520 warn!(
521 status = status.as_u16(),
522 attempt = attempts,
523 wait_ms = wait.as_millis(),
524 "Raw request failed, retrying"
525 );
526 tokio::time::sleep(wait).await;
527 continue;
528 }
529 }
530
531 let headers = response
532 .headers()
533 .iter()
534 .map(|(name, value)| {
535 (
536 name.as_str().to_string(),
537 value.to_str().unwrap_or_default().to_string(),
538 )
539 })
540 .collect();
541 let body = response
542 .bytes()
543 .await
544 .map_err(|err| ApiError::InvalidResponse(err.to_string()))?
545 .to_vec();
546
547 return Ok(RawResponse {
548 status: status.as_u16(),
549 headers,
550 body,
551 });
552 }
553 }
554
555 pub async fn get_bytes(&self, path: &str) -> Result<Vec<u8>> {
558 if let Some(wait_secs) = self.rate_limiter.check_limit().await {
559 warn!(wait_secs, "Rate limit reached, waiting");
560 tokio::time::sleep(Duration::from_secs(wait_secs)).await;
561 }
562
563 let joined = self.safe_join(path)?;
564
565 debug!(method = "GET", url = %joined, "Sending bytes request");
566
567 let result = retry_with_backoff(&self.retry_config, || async {
568 let mut req = self.client.request(Method::GET, joined.clone());
569 req = self.apply_auth(req);
570
571 let response = req.send().await.map_err(ApiError::RequestFailed)?;
572
573 self.rate_limiter.update_from_response(&response).await;
574
575 let status = response.status();
576
577 match status {
578 StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationFailed {
579 message: "Invalid or expired credentials".to_string(),
580 }),
581 StatusCode::FORBIDDEN => {
582 let message = response
583 .text()
584 .await
585 .unwrap_or_else(|_| "Access forbidden".to_string());
586 Err(ApiError::Forbidden { message })
587 }
588 StatusCode::NOT_FOUND => {
589 let resource = joined.path().to_string();
590 Err(ApiError::NotFound { resource })
591 }
592 StatusCode::GONE => {
593 let message = response
594 .text()
595 .await
596 .unwrap_or_else(|_| "API endpoint has been removed".to_string());
597 Err(ApiError::EndpointGone { message })
598 }
599 StatusCode::TOO_MANY_REQUESTS => {
600 let retry_after = response
601 .headers()
602 .get("retry-after")
603 .and_then(|v| v.to_str().ok())
604 .and_then(|s| s.parse().ok())
605 .unwrap_or(60);
606 Err(ApiError::RateLimitExceeded { retry_after })
607 }
608 status if status.is_success() => {
609 response.bytes().await.map(|b| b.to_vec()).map_err(|e| {
610 error!("Failed to read bytes response: {}", e);
611 ApiError::InvalidResponse(e.to_string())
612 })
613 }
614 _ => {
615 let message = response
616 .text()
617 .await
618 .unwrap_or_else(|_| format!("Unexpected status: {}", status));
619 Err(ApiError::ServerError {
620 status: status.as_u16(),
621 message,
622 })
623 }
624 }
625 })
626 .await?;
627
628 Ok(result)
629 }
630
631 pub async fn request<T: DeserializeOwned, B: Serialize + ?Sized>(
632 &self,
633 method: Method,
634 path: &str,
635 body: Option<&B>,
636 ) -> Result<T> {
637 if let Some(wait_secs) = self.rate_limiter.check_limit().await {
638 warn!(wait_secs, "Rate limit reached, waiting");
639 tokio::time::sleep(Duration::from_secs(wait_secs)).await;
640 }
641
642 let joined = self.safe_join(path)?;
643
644 debug!(method = %method, url = %joined, "Sending request");
645
646 let result = retry_with_backoff(&self.retry_config, || async {
647 let mut req = self.client.request(method.clone(), joined.clone());
648 req = self.apply_auth(req);
649
650 if let Some(body) = body {
651 req = req.json(body);
652 }
653
654 let response = req.send().await.map_err(ApiError::RequestFailed)?;
655
656 self.rate_limiter.update_from_response(&response).await;
657
658 let status = response.status();
659
660 match status {
661 StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationFailed {
662 message: "Invalid or expired credentials".to_string(),
663 }),
664 StatusCode::FORBIDDEN => {
665 let message = response
666 .text()
667 .await
668 .unwrap_or_else(|_| "Access forbidden".to_string());
669 Err(ApiError::Forbidden { message })
670 }
671 StatusCode::NOT_FOUND => {
672 let resource = joined.path().to_string();
673 Err(ApiError::NotFound { resource })
674 }
675 StatusCode::BAD_REQUEST => {
676 let message = response
677 .text()
678 .await
679 .unwrap_or_else(|_| "Bad request".to_string());
680 Err(ApiError::BadRequest { message })
681 }
682 StatusCode::GONE => {
683 let message = response
684 .text()
685 .await
686 .unwrap_or_else(|_| "API endpoint has been removed".to_string());
687 Err(ApiError::EndpointGone { message })
688 }
689 StatusCode::TOO_MANY_REQUESTS => {
690 let retry_after = response
691 .headers()
692 .get("retry-after")
693 .and_then(|v| v.to_str().ok())
694 .and_then(|s| s.parse().ok())
695 .unwrap_or(60);
696 Err(ApiError::RateLimitExceeded { retry_after })
697 }
698 status if status.is_server_error() => {
699 let message = response
700 .text()
701 .await
702 .unwrap_or_else(|_| "Server error".to_string());
703 Err(ApiError::ServerError {
704 status: status.as_u16(),
705 message,
706 })
707 }
708 status if status.is_success() => {
709 let bytes = response
710 .bytes()
711 .await
712 .map_err(|e| ApiError::InvalidResponse(e.to_string()))?;
713 let slice: &[u8] = if bytes.iter().all(|b| b.is_ascii_whitespace()) {
719 b"null"
720 } else {
721 &bytes
722 };
723 serde_json::from_slice::<T>(slice).map_err(|e| {
724 error!("Failed to parse JSON response: {}", e);
725 ApiError::InvalidResponse(e.to_string())
726 })
727 }
728 _ => {
729 let message = response
730 .text()
731 .await
732 .unwrap_or_else(|_| format!("Unexpected status: {}", status));
733 Err(ApiError::ServerError {
734 status: status.as_u16(),
735 message,
736 })
737 }
738 }
739 })
740 .await?;
741
742 Ok(result)
743 }
744
745 pub fn apply_auth(&self, request: RequestBuilder) -> RequestBuilder {
746 match &self.auth {
747 Some(AuthMethod::Basic { username, token }) => {
748 request.basic_auth(username, Some(token.expose_secret()))
749 }
750 Some(AuthMethod::Bearer { token }) => request.bearer_auth(token.expose_secret()),
751 Some(AuthMethod::GenieKey { api_key }) => request.header(
752 "Authorization",
753 format!("GenieKey {}", api_key.expose_secret()),
754 ),
755 None => request,
756 }
757 }
758
759 pub fn rate_limiter(&self) -> &RateLimiter {
760 &self.rate_limiter
761 }
762}
763
764#[cfg(test)]
765mod tests {
766 use super::*;
767 use wiremock::matchers::{body_string, header, method, path};
768 use wiremock::{Mock, MockServer, ResponseTemplate};
769
770 #[tokio::test]
771 async fn test_403_returns_forbidden() {
772 let server = MockServer::start().await;
773 Mock::given(method("GET"))
774 .and(path("test"))
775 .respond_with(ResponseTemplate::new(403).set_body_string("You do not have access"))
776 .mount(&server)
777 .await;
778
779 let client = ApiClient::new(server.uri()).unwrap();
780 let result: error::Result<serde_json::Value> = client.get("/test").await;
781
782 match result {
783 Err(ApiError::Forbidden { message }) => {
784 assert!(message.contains("You do not have access"));
785 }
786 other => panic!("Expected Forbidden, got: {:?}", other),
787 }
788 }
789
790 #[tokio::test]
791 async fn test_401_returns_authentication_failed() {
792 let server = MockServer::start().await;
793 Mock::given(method("GET"))
794 .and(path("test"))
795 .respond_with(ResponseTemplate::new(401))
796 .mount(&server)
797 .await;
798
799 let client = ApiClient::new(server.uri()).unwrap();
800 let result: error::Result<serde_json::Value> = client.get("/test").await;
801
802 match result {
803 Err(ApiError::AuthenticationFailed { .. }) => {}
804 other => panic!("Expected AuthenticationFailed, got: {:?}", other),
805 }
806 }
807
808 #[tokio::test]
809 async fn test_403_get_text_returns_forbidden() {
810 let server = MockServer::start().await;
811 Mock::given(method("GET"))
812 .and(path("text-endpoint"))
813 .respond_with(ResponseTemplate::new(403).set_body_string("Forbidden resource"))
814 .mount(&server)
815 .await;
816
817 let client = ApiClient::new(server.uri()).unwrap();
818 let result = client.get_text("/text-endpoint").await;
819
820 match result {
821 Err(ApiError::Forbidden { message }) => {
822 assert!(message.contains("Forbidden resource"));
823 }
824 other => panic!("Expected Forbidden, got: {:?}", other),
825 }
826 }
827
828 #[tokio::test]
829 async fn test_403_get_bytes_returns_forbidden() {
830 let server = MockServer::start().await;
831 Mock::given(method("GET"))
832 .and(path("bytes-endpoint"))
833 .respond_with(ResponseTemplate::new(403).set_body_string("Access denied"))
834 .mount(&server)
835 .await;
836
837 let client = ApiClient::new(server.uri()).unwrap();
838 let result = client.get_bytes("/bytes-endpoint").await;
839
840 match result {
841 Err(ApiError::Forbidden { message }) => {
842 assert!(message.contains("Access denied"));
843 }
844 other => panic!("Expected Forbidden, got: {:?}", other),
845 }
846 }
847
848 #[tokio::test]
851 async fn test_204_no_content_put_succeeds() {
852 let server = MockServer::start().await;
853 Mock::given(method("PUT"))
854 .and(path("issue/AEA-1"))
855 .respond_with(ResponseTemplate::new(204))
856 .mount(&server)
857 .await;
858
859 let client = ApiClient::new(server.uri()).unwrap();
860 let result: error::Result<serde_json::Value> = client
861 .put("/issue/AEA-1", &serde_json::json!({"fields": {}}))
862 .await;
863
864 match result {
865 Ok(serde_json::Value::Null) => {}
866 other => panic!("Expected Ok(Null) for 204, got: {:?}", other),
867 }
868 }
869
870 #[tokio::test]
872 async fn test_200_empty_body_succeeds() {
873 let server = MockServer::start().await;
874 Mock::given(method("POST"))
875 .and(path("transitions"))
876 .respond_with(ResponseTemplate::new(200).set_body_string(" \n"))
877 .mount(&server)
878 .await;
879
880 let client = ApiClient::new(server.uri()).unwrap();
881 let result: error::Result<serde_json::Value> =
882 client.post("/transitions", &serde_json::json!({})).await;
883
884 match result {
885 Ok(serde_json::Value::Null) => {}
886 other => panic!("Expected Ok(Null) for empty 200, got: {:?}", other),
887 }
888 }
889
890 #[tokio::test]
892 async fn test_200_json_body_still_parses() {
893 let server = MockServer::start().await;
894 Mock::given(method("GET"))
895 .and(path("issue/AEA-1"))
896 .respond_with(
897 ResponseTemplate::new(200).set_body_json(serde_json::json!({"key": "AEA-1"})),
898 )
899 .mount(&server)
900 .await;
901
902 let client = ApiClient::new(server.uri()).unwrap();
903 let result: serde_json::Value = client.get("/issue/AEA-1").await.unwrap();
904 assert_eq!(result["key"], "AEA-1");
905 }
906
907 #[tokio::test]
914 async fn test_request_raw_surfaces_non_2xx_without_erroring() {
915 let server = MockServer::start().await;
916 Mock::given(method("GET"))
917 .and(path("/rest/api/3/issue/NOPE-1"))
918 .respond_with(
919 ResponseTemplate::new(404)
920 .set_body_json(serde_json::json!({"errorMessages": ["Issue does not exist"]})),
921 )
922 .mount(&server)
923 .await;
924
925 let client = ApiClient::new(server.uri()).unwrap();
926 let response = client
927 .request_raw(RawRequest {
928 method: Method::GET,
929 path: "/rest/api/3/issue/NOPE-1",
930 headers: HeaderMap::new(),
931 body: None,
932 timeout: None,
933 })
934 .await
935 .unwrap();
936
937 assert_eq!(response.status, 404);
938 assert!(!response.is_success());
939 assert!(response
940 .header("Content-Type")
941 .unwrap()
942 .contains("application/json"));
943 assert!(String::from_utf8_lossy(&response.body).contains("Issue does not exist"));
944 }
945
946 #[tokio::test]
947 async fn test_request_raw_applies_headers_and_body() {
948 let server = MockServer::start().await;
949 Mock::given(method("POST"))
950 .and(path("/rest/api/3/issue"))
951 .and(header("X-Atlassian-Token", "no-check"))
952 .and(body_string("{\"fields\":{}}"))
953 .respond_with(
954 ResponseTemplate::new(201).set_body_json(serde_json::json!({"key": "A-1"})),
955 )
956 .mount(&server)
957 .await;
958
959 let mut headers = HeaderMap::new();
960 headers.insert("X-Atlassian-Token", "no-check".parse().unwrap());
961
962 let client = ApiClient::new(server.uri()).unwrap();
963 let response = client
964 .request_raw(RawRequest {
965 method: Method::POST,
966 path: "/rest/api/3/issue",
967 headers,
968 body: Some(b"{\"fields\":{}}"),
969 timeout: None,
970 })
971 .await
972 .unwrap();
973
974 assert_eq!(response.status, 201);
975 }
976
977 #[tokio::test]
978 async fn test_request_raw_retries_5xx_for_get() {
979 let server = MockServer::start().await;
980 Mock::given(method("GET"))
981 .and(path("/flaky"))
982 .respond_with(ResponseTemplate::new(500))
983 .expect(3)
984 .mount(&server)
985 .await;
986
987 let client = ApiClient::new(server.uri())
988 .unwrap()
989 .with_retry_config(RetryConfig {
990 initial_interval: Duration::from_millis(1),
991 ..RetryConfig::default()
992 });
993 let response = client
994 .request_raw(RawRequest {
995 method: Method::GET,
996 path: "/flaky",
997 headers: HeaderMap::new(),
998 body: None,
999 timeout: None,
1000 })
1001 .await
1002 .unwrap();
1003
1004 assert_eq!(response.status, 500);
1005 }
1006
1007 #[tokio::test]
1010 async fn test_request_raw_never_retries_post() {
1011 let server = MockServer::start().await;
1012 Mock::given(method("POST"))
1013 .and(path("/create"))
1014 .respond_with(ResponseTemplate::new(503))
1015 .expect(1)
1016 .mount(&server)
1017 .await;
1018
1019 let client = ApiClient::new(server.uri())
1020 .unwrap()
1021 .with_retry_config(RetryConfig {
1022 initial_interval: Duration::from_millis(1),
1023 ..RetryConfig::default()
1024 });
1025 let response = client
1026 .request_raw(RawRequest {
1027 method: Method::POST,
1028 path: "/create",
1029 headers: HeaderMap::new(),
1030 body: Some(b"{}"),
1031 timeout: None,
1032 })
1033 .await
1034 .unwrap();
1035
1036 assert_eq!(response.status, 503);
1037 }
1038
1039 #[tokio::test]
1040 async fn test_request_raw_rejects_cross_host_path() {
1041 let server = MockServer::start().await;
1042 Mock::given(method("GET"))
1043 .respond_with(ResponseTemplate::new(200))
1044 .expect(0)
1045 .mount(&server)
1046 .await;
1047
1048 let client = ApiClient::new(server.uri()).unwrap();
1049 let err = client
1050 .request_raw(RawRequest {
1051 method: Method::GET,
1052 path: "https://evil.example.com/steal",
1053 headers: HeaderMap::new(),
1054 body: None,
1055 timeout: None,
1056 })
1057 .await
1058 .unwrap_err();
1059
1060 assert!(matches!(err, ApiError::InvalidUrl(_)), "got {err:?}");
1061 }
1062
1063 #[test]
1064 fn test_resolve_url_enforces_same_origin() {
1065 let client = ApiClient::new("https://site.atlassian.net").unwrap();
1066
1067 assert_eq!(
1068 client.resolve_url("/rest/api/3/myself").unwrap().as_str(),
1069 "https://site.atlassian.net/rest/api/3/myself"
1070 );
1071 assert_eq!(
1073 client.resolve_url("rest/api/3/myself").unwrap().as_str(),
1074 "https://site.atlassian.net/rest/api/3/myself"
1075 );
1076 for bad in [
1078 "https://evil.example.com/x",
1079 "http://site.atlassian.net/x",
1080 "https://site.atlassian.net@evil.example.com/",
1081 "//evil.example.com/x",
1082 ] {
1083 let resolved = client.resolve_url(bad);
1084 match resolved {
1085 Err(_) => {}
1086 Ok(url) => assert_eq!(url.host_str(), Some("site.atlassian.net"), "{bad}"),
1089 }
1090 }
1091 }
1092
1093 #[tokio::test]
1096 async fn test_request_raw_rejects_a_different_port_on_the_same_host() {
1097 let victim = MockServer::start().await;
1098 Mock::given(method("GET"))
1099 .respond_with(ResponseTemplate::new(200).set_body_string("secrets"))
1100 .expect(0)
1101 .mount(&victim)
1102 .await;
1103
1104 let server = MockServer::start().await;
1105 let client = ApiClient::new(server.uri()).unwrap();
1106 let err = client
1107 .request_raw(RawRequest {
1108 method: Method::GET,
1109 path: &format!("{}/steal", victim.uri()),
1110 headers: HeaderMap::new(),
1111 body: None,
1112 timeout: None,
1113 })
1114 .await
1115 .unwrap_err();
1116
1117 assert!(matches!(err, ApiError::InvalidUrl(_)), "got {err:?}");
1118 }
1119
1120 #[tokio::test]
1124 async fn test_request_raw_does_not_follow_a_cross_origin_redirect() {
1125 let evil = MockServer::start().await;
1126 Mock::given(method("POST"))
1127 .respond_with(ResponseTemplate::new(200).set_body_string("pwned"))
1128 .expect(0)
1129 .mount(&evil)
1130 .await;
1131
1132 let server = MockServer::start().await;
1133 Mock::given(method("POST"))
1134 .and(path("/rest/api/3/bounce"))
1135 .respond_with(
1136 ResponseTemplate::new(307)
1137 .insert_header("location", format!("{}/steal", evil.uri()).as_str()),
1138 )
1139 .mount(&server)
1140 .await;
1141
1142 let client = ApiClient::new(server.uri())
1143 .unwrap()
1144 .with_basic_auth("dev@example.com", "token");
1145 let response = client
1146 .request_raw(RawRequest {
1147 method: Method::POST,
1148 path: "/rest/api/3/bounce",
1149 headers: HeaderMap::new(),
1150 body: Some(b"{}"),
1151 timeout: None,
1152 })
1153 .await
1154 .unwrap();
1155
1156 assert_eq!(response.status, 307);
1157 assert!(response.header("location").unwrap().contains("/steal"));
1158 assert_ne!(response.body, b"pwned".to_vec());
1159 }
1160
1161 #[tokio::test]
1163 async fn test_request_raw_follows_a_same_origin_redirect() {
1164 let server = MockServer::start().await;
1165 Mock::given(method("GET"))
1166 .and(path("/from"))
1167 .respond_with(ResponseTemplate::new(302).insert_header("location", "/to"))
1168 .mount(&server)
1169 .await;
1170 Mock::given(method("GET"))
1171 .and(path("/to"))
1172 .respond_with(ResponseTemplate::new(200).set_body_string("arrived"))
1173 .mount(&server)
1174 .await;
1175
1176 let client = ApiClient::new(server.uri()).unwrap();
1177 let response = client
1178 .request_raw(RawRequest {
1179 method: Method::GET,
1180 path: "/from",
1181 headers: HeaderMap::new(),
1182 body: None,
1183 timeout: None,
1184 })
1185 .await
1186 .unwrap();
1187
1188 assert_eq!(response.status, 200);
1189 assert_eq!(response.body, b"arrived".to_vec());
1190 }
1191
1192 #[tokio::test]
1195 async fn test_get_bytes_still_follows_cross_host_redirects() {
1196 let media = MockServer::start().await;
1197 Mock::given(method("GET"))
1198 .and(path("/file/binary"))
1199 .respond_with(ResponseTemplate::new(200).set_body_bytes(b"BYTES".to_vec()))
1200 .mount(&media)
1201 .await;
1202
1203 let server = MockServer::start().await;
1204 Mock::given(method("GET"))
1205 .and(path("/content/1"))
1206 .respond_with(
1207 ResponseTemplate::new(302)
1208 .insert_header("location", format!("{}/file/binary", media.uri()).as_str()),
1209 )
1210 .mount(&server)
1211 .await;
1212
1213 let client = ApiClient::new(server.uri()).unwrap();
1214 assert_eq!(client.get_bytes("/content/1").await.unwrap(), b"BYTES");
1215 }
1216
1217 #[test]
1218 fn test_same_origin_compares_scheme_host_and_port() {
1219 let base = Url::parse("https://site.atlassian.net").unwrap();
1220 assert!(same_origin(
1221 &Url::parse("https://site.atlassian.net/x").unwrap(),
1222 &base
1223 ));
1224 assert!(same_origin(
1226 &Url::parse("https://site.atlassian.net:443/x").unwrap(),
1227 &base
1228 ));
1229 for other in [
1230 "https://site.atlassian.net:8443/x",
1231 "http://site.atlassian.net/x",
1232 "https://evil.example.com/x",
1233 ] {
1234 assert!(
1235 !same_origin(&Url::parse(other).unwrap(), &base),
1236 "{other} must not match"
1237 );
1238 }
1239 }
1240}