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
65pub fn normalize_base_url(mut url: Url) -> Url {
70 if url.cannot_be_a_base() {
71 return url;
72 }
73
74 let path = url.path();
75 if !path.ends_with('/') {
76 url.set_path(&format!("{path}/"));
77 }
78 url
79}
80
81fn retry_after(response: &reqwest::Response) -> Option<Duration> {
84 response
85 .headers()
86 .get(reqwest::header::RETRY_AFTER)?
87 .to_str()
88 .ok()?
89 .trim()
90 .parse::<u64>()
91 .ok()
92 .map(Duration::from_secs)
93}
94
95pub struct RawRequest<'a> {
97 pub method: Method,
98 pub path: &'a str,
100 pub headers: HeaderMap,
101 pub body: Option<&'a [u8]>,
102 pub timeout: Option<Duration>,
104}
105
106#[derive(Debug, Clone)]
108pub struct RawResponse {
109 pub status: u16,
110 pub headers: Vec<(String, String)>,
111 pub body: Vec<u8>,
112}
113
114impl RawResponse {
115 pub fn is_success(&self) -> bool {
116 (200..300).contains(&self.status)
117 }
118
119 pub fn header(&self, name: &str) -> Option<&str> {
121 self.headers
122 .iter()
123 .find(|(key, _)| key.eq_ignore_ascii_case(name))
124 .map(|(_, value)| value.as_str())
125 }
126}
127
128#[derive(Clone)]
129pub struct ApiClient {
130 client: Client,
131 raw_client: Client,
133 base_url: Url,
134 auth: Option<AuthMethod>,
135 retry_config: RetryConfig,
136 rate_limiter: RateLimiter,
137}
138
139impl ApiClient {
140 pub fn new(base_url: impl AsRef<str>) -> Result<Self> {
141 let url = Url::parse(base_url.as_ref()).map_err(ApiError::InvalidUrl)?;
142
143 if url.scheme() != "https" {
146 let is_localhost = url
147 .host_str()
148 .map(|h| h == "localhost" || h == "127.0.0.1" || h.starts_with("127."))
149 .unwrap_or(false);
150
151 if !is_localhost {
152 return Err(ApiError::InvalidUrl(
153 url::ParseError::InvalidDomainCharacter,
154 ));
155 }
156 }
157
158 let url = normalize_base_url(url);
159
160 let client = Client::builder()
161 .user_agent(format!("atlassian-cli/{}", env!("CARGO_PKG_VERSION")))
162 .timeout(Duration::from_secs(30))
163 .build()
164 .map_err(ApiError::RequestFailed)?;
165
166 let origin = url.clone();
176 let raw_client = Client::builder()
177 .user_agent(format!("atlassian-cli/{}", env!("CARGO_PKG_VERSION")))
178 .timeout(Duration::from_secs(30))
179 .redirect(reqwest::redirect::Policy::custom(move |attempt| {
180 if attempt.previous().len() >= 10 {
181 attempt.error("too many redirects")
182 } else if same_origin(attempt.url(), &origin) {
183 attempt.follow()
184 } else {
185 attempt.stop()
186 }
187 }))
188 .build()
189 .map_err(ApiError::RequestFailed)?;
190
191 Ok(Self {
192 client,
193 raw_client,
194 base_url: url,
195 auth: None,
196 retry_config: RetryConfig::default(),
197 rate_limiter: RateLimiter::new(),
198 })
199 }
200
201 fn safe_join(&self, path: &str) -> Result<Url> {
204 let joined = self
205 .base_url
206 .join(path.strip_prefix('/').unwrap_or(path))
207 .map_err(ApiError::InvalidUrl)?;
208
209 if !same_origin(&joined, &self.base_url) {
210 return Err(ApiError::InvalidUrl(
211 url::ParseError::InvalidDomainCharacter,
212 ));
213 }
214
215 Ok(joined)
216 }
217
218 pub fn with_basic_auth(
219 mut self,
220 username: impl Into<String>,
221 token: impl Into<String>,
222 ) -> Self {
223 self.auth = Some(AuthMethod::Basic {
224 username: username.into(),
225 token: SecretString::from(token.into()),
226 });
227 self
228 }
229
230 pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
231 self.auth = Some(AuthMethod::Bearer {
232 token: SecretString::from(token.into()),
233 });
234 self
235 }
236
237 pub fn with_genie_key(mut self, api_key: impl Into<String>) -> Self {
238 self.auth = Some(AuthMethod::GenieKey {
239 api_key: SecretString::from(api_key.into()),
240 });
241 self
242 }
243
244 pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
245 self.retry_config = config;
246 self
247 }
248
249 pub fn base_url(&self) -> &str {
250 self.base_url.as_str()
251 }
252
253 pub fn http_client(&self) -> &Client {
255 &self.client
256 }
257
258 pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
259 self.request(Method::GET, path, Option::<&()>::None).await
260 }
261
262 pub async fn post<T: DeserializeOwned, B: Serialize + ?Sized>(
263 &self,
264 path: &str,
265 body: &B,
266 ) -> Result<T> {
267 self.request(Method::POST, path, Some(body)).await
268 }
269
270 pub async fn put<T: DeserializeOwned, B: Serialize + ?Sized>(
271 &self,
272 path: &str,
273 body: &B,
274 ) -> Result<T> {
275 self.request(Method::PUT, path, Some(body)).await
276 }
277
278 pub async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
279 self.request(Method::DELETE, path, Option::<&()>::None)
280 .await
281 }
282
283 pub async fn delete_with_body<T: DeserializeOwned, B: Serialize + ?Sized>(
284 &self,
285 path: &str,
286 body: &B,
287 ) -> Result<T> {
288 self.request(Method::DELETE, path, Some(body)).await
289 }
290
291 pub async fn delete_no_content(&self, path: &str) -> Result<()> {
293 if let Some(wait_secs) = self.rate_limiter.check_limit().await {
294 warn!(wait_secs, "Rate limit reached, waiting");
295 tokio::time::sleep(Duration::from_secs(wait_secs)).await;
296 }
297
298 let joined = self.safe_join(path)?;
299
300 debug!(method = "DELETE", url = %joined, "Sending delete (no content) request");
301
302 retry_with_backoff(&self.retry_config, || async {
303 let mut req = self.client.request(Method::DELETE, joined.clone());
304 req = self.apply_auth(req);
305
306 let response = req.send().await.map_err(ApiError::RequestFailed)?;
307
308 self.rate_limiter.update_from_response(&response).await;
309
310 let status = response.status();
311
312 match status {
313 StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationFailed {
314 message: "Invalid or expired credentials".to_string(),
315 }),
316 StatusCode::FORBIDDEN => {
317 let message = response
318 .text()
319 .await
320 .unwrap_or_else(|_| "Access forbidden".to_string());
321 Err(ApiError::Forbidden { message })
322 }
323 StatusCode::NOT_FOUND => {
324 let resource = joined.path().to_string();
325 Err(ApiError::NotFound { resource })
326 }
327 StatusCode::BAD_REQUEST => {
328 let message = response
329 .text()
330 .await
331 .unwrap_or_else(|_| "Bad request".to_string());
332 Err(ApiError::BadRequest { message })
333 }
334 StatusCode::GONE => {
335 let message = response
336 .text()
337 .await
338 .unwrap_or_else(|_| "API endpoint has been removed".to_string());
339 Err(ApiError::EndpointGone { message })
340 }
341 StatusCode::TOO_MANY_REQUESTS => {
342 let retry_after = response
343 .headers()
344 .get("retry-after")
345 .and_then(|v| v.to_str().ok())
346 .and_then(|s| s.parse().ok())
347 .unwrap_or(60);
348 Err(ApiError::RateLimitExceeded { retry_after })
349 }
350 status if status.is_server_error() => {
351 let message = response
352 .text()
353 .await
354 .unwrap_or_else(|_| "Server error".to_string());
355 Err(ApiError::ServerError {
356 status: status.as_u16(),
357 message,
358 })
359 }
360 status if status.is_success() => Ok(()),
361 _ => {
362 let message = response
363 .text()
364 .await
365 .unwrap_or_else(|_| format!("Unexpected status: {}", status));
366 Err(ApiError::ServerError {
367 status: status.as_u16(),
368 message,
369 })
370 }
371 }
372 })
373 .await
374 }
375
376 pub async fn get_text(&self, path: &str) -> Result<String> {
380 if let Some(wait_secs) = self.rate_limiter.check_limit().await {
381 warn!(wait_secs, "Rate limit reached, waiting");
382 tokio::time::sleep(Duration::from_secs(wait_secs)).await;
383 }
384
385 let joined = self.safe_join(path)?;
386
387 debug!(method = "GET", url = %joined, "Sending text request");
388
389 let result = retry_with_backoff(&self.retry_config, || async {
390 let mut req = self.client.request(Method::GET, joined.clone());
391 req = self.apply_auth(req);
392 req = req.header("Accept", "text/plain, */*;q=0.1");
393
394 let response = req.send().await.map_err(ApiError::RequestFailed)?;
395
396 self.rate_limiter.update_from_response(&response).await;
397
398 let status = response.status();
399
400 match status {
401 StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationFailed {
402 message: "Invalid or expired credentials".to_string(),
403 }),
404 StatusCode::FORBIDDEN => {
405 let message = response
406 .text()
407 .await
408 .unwrap_or_else(|_| "Access forbidden".to_string());
409 Err(ApiError::Forbidden { message })
410 }
411 StatusCode::NOT_FOUND => {
412 let resource = joined.path().to_string();
413 Err(ApiError::NotFound { resource })
414 }
415 StatusCode::BAD_REQUEST => {
416 let message = response
417 .text()
418 .await
419 .unwrap_or_else(|_| "Bad request".to_string());
420 Err(ApiError::BadRequest { message })
421 }
422 StatusCode::NOT_ACCEPTABLE => {
423 let message = response
424 .text()
425 .await
426 .unwrap_or_else(|_| "Content not acceptable".to_string());
427 Err(ApiError::ServerError {
428 status: 406,
429 message,
430 })
431 }
432 StatusCode::GONE => {
433 let message = response
434 .text()
435 .await
436 .unwrap_or_else(|_| "API endpoint has been removed".to_string());
437 Err(ApiError::EndpointGone { message })
438 }
439 StatusCode::TOO_MANY_REQUESTS => {
440 let retry_after = response
441 .headers()
442 .get("retry-after")
443 .and_then(|v| v.to_str().ok())
444 .and_then(|s| s.parse().ok())
445 .unwrap_or(60);
446 Err(ApiError::RateLimitExceeded { retry_after })
447 }
448 status if status.is_server_error() => {
449 let message = response
450 .text()
451 .await
452 .unwrap_or_else(|_| "Server error".to_string());
453 Err(ApiError::ServerError {
454 status: status.as_u16(),
455 message,
456 })
457 }
458 status if status.is_success() => response.text().await.map_err(|e| {
459 error!("Failed to read text response: {}", e);
460 ApiError::InvalidResponse(e.to_string())
461 }),
462 _ => {
463 let message = response
464 .text()
465 .await
466 .unwrap_or_else(|_| format!("Unexpected status: {}", status));
467 Err(ApiError::ServerError {
468 status: status.as_u16(),
469 message,
470 })
471 }
472 }
473 })
474 .await?;
475
476 Ok(result)
477 }
478
479 pub fn resolve_url(&self, path: &str) -> Result<Url> {
483 self.safe_join(path)
484 }
485
486 pub async fn request_raw(&self, req: RawRequest<'_>) -> Result<RawResponse> {
497 if let Some(wait_secs) = self.rate_limiter.check_limit().await {
498 warn!(wait_secs, "Rate limit reached, waiting");
499 tokio::time::sleep(Duration::from_secs(wait_secs)).await;
500 }
501
502 let joined = self.safe_join(req.path)?;
503 debug!(method = %req.method, url = %joined, "Sending raw request");
504
505 let idempotent = matches!(
506 req.method,
507 Method::GET | Method::HEAD | Method::PUT | Method::DELETE | Method::OPTIONS
508 );
509 let mut backoff = self.retry_config.backoff();
513 let mut attempts = 0usize;
514
515 loop {
516 attempts += 1;
517
518 let mut builder = self.raw_client.request(req.method.clone(), joined.clone());
519 builder = self.apply_auth(builder);
520 builder = builder.headers(req.headers.clone());
521 if let Some(body) = req.body {
522 builder = builder.body(body.to_vec());
523 }
524 if let Some(timeout) = req.timeout {
525 builder = builder.timeout(timeout);
526 }
527
528 let response = builder.send().await.map_err(ApiError::RequestFailed)?;
529 self.rate_limiter.update_from_response(&response).await;
530 let status = response.status();
531
532 let retryable = status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error();
533 if idempotent && retryable && attempts < self.retry_config.max_retries {
534 if let Some(wait) = backoff.next_backoff() {
535 let wait = retry_after(&response).unwrap_or(wait);
538 warn!(
539 status = status.as_u16(),
540 attempt = attempts,
541 wait_ms = wait.as_millis(),
542 "Raw request failed, retrying"
543 );
544 tokio::time::sleep(wait).await;
545 continue;
546 }
547 }
548
549 let headers = response
550 .headers()
551 .iter()
552 .map(|(name, value)| {
553 (
554 name.as_str().to_string(),
555 value.to_str().unwrap_or_default().to_string(),
556 )
557 })
558 .collect();
559 let body = response
560 .bytes()
561 .await
562 .map_err(|err| ApiError::InvalidResponse(err.to_string()))?
563 .to_vec();
564
565 return Ok(RawResponse {
566 status: status.as_u16(),
567 headers,
568 body,
569 });
570 }
571 }
572
573 pub async fn get_bytes(&self, path: &str) -> Result<Vec<u8>> {
576 if let Some(wait_secs) = self.rate_limiter.check_limit().await {
577 warn!(wait_secs, "Rate limit reached, waiting");
578 tokio::time::sleep(Duration::from_secs(wait_secs)).await;
579 }
580
581 let joined = self.safe_join(path)?;
582
583 debug!(method = "GET", url = %joined, "Sending bytes request");
584
585 let result = retry_with_backoff(&self.retry_config, || async {
586 let mut req = self.client.request(Method::GET, joined.clone());
587 req = self.apply_auth(req);
588
589 let response = req.send().await.map_err(ApiError::RequestFailed)?;
590
591 self.rate_limiter.update_from_response(&response).await;
592
593 let status = response.status();
594
595 match status {
596 StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationFailed {
597 message: "Invalid or expired credentials".to_string(),
598 }),
599 StatusCode::FORBIDDEN => {
600 let message = response
601 .text()
602 .await
603 .unwrap_or_else(|_| "Access forbidden".to_string());
604 Err(ApiError::Forbidden { message })
605 }
606 StatusCode::NOT_FOUND => {
607 let resource = joined.path().to_string();
608 Err(ApiError::NotFound { resource })
609 }
610 StatusCode::GONE => {
611 let message = response
612 .text()
613 .await
614 .unwrap_or_else(|_| "API endpoint has been removed".to_string());
615 Err(ApiError::EndpointGone { message })
616 }
617 StatusCode::TOO_MANY_REQUESTS => {
618 let retry_after = response
619 .headers()
620 .get("retry-after")
621 .and_then(|v| v.to_str().ok())
622 .and_then(|s| s.parse().ok())
623 .unwrap_or(60);
624 Err(ApiError::RateLimitExceeded { retry_after })
625 }
626 status if status.is_success() => {
627 response.bytes().await.map(|b| b.to_vec()).map_err(|e| {
628 error!("Failed to read bytes response: {}", e);
629 ApiError::InvalidResponse(e.to_string())
630 })
631 }
632 _ => {
633 let message = response
634 .text()
635 .await
636 .unwrap_or_else(|_| format!("Unexpected status: {}", status));
637 Err(ApiError::ServerError {
638 status: status.as_u16(),
639 message,
640 })
641 }
642 }
643 })
644 .await?;
645
646 Ok(result)
647 }
648
649 pub async fn request<T: DeserializeOwned, B: Serialize + ?Sized>(
650 &self,
651 method: Method,
652 path: &str,
653 body: Option<&B>,
654 ) -> Result<T> {
655 if let Some(wait_secs) = self.rate_limiter.check_limit().await {
656 warn!(wait_secs, "Rate limit reached, waiting");
657 tokio::time::sleep(Duration::from_secs(wait_secs)).await;
658 }
659
660 let joined = self.safe_join(path)?;
661
662 debug!(method = %method, url = %joined, "Sending request");
663
664 let result = retry_with_backoff(&self.retry_config, || async {
665 let mut req = self.client.request(method.clone(), joined.clone());
666 req = self.apply_auth(req);
667
668 if let Some(body) = body {
669 req = req.json(body);
670 }
671
672 let response = req.send().await.map_err(ApiError::RequestFailed)?;
673
674 self.rate_limiter.update_from_response(&response).await;
675
676 let status = response.status();
677
678 match status {
679 StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationFailed {
680 message: "Invalid or expired credentials".to_string(),
681 }),
682 StatusCode::FORBIDDEN => {
683 let message = response
684 .text()
685 .await
686 .unwrap_or_else(|_| "Access forbidden".to_string());
687 Err(ApiError::Forbidden { message })
688 }
689 StatusCode::NOT_FOUND => {
690 let resource = joined.path().to_string();
691 Err(ApiError::NotFound { resource })
692 }
693 StatusCode::BAD_REQUEST => {
694 let message = response
695 .text()
696 .await
697 .unwrap_or_else(|_| "Bad request".to_string());
698 Err(ApiError::BadRequest { message })
699 }
700 StatusCode::GONE => {
701 let message = response
702 .text()
703 .await
704 .unwrap_or_else(|_| "API endpoint has been removed".to_string());
705 Err(ApiError::EndpointGone { message })
706 }
707 StatusCode::TOO_MANY_REQUESTS => {
708 let retry_after = response
709 .headers()
710 .get("retry-after")
711 .and_then(|v| v.to_str().ok())
712 .and_then(|s| s.parse().ok())
713 .unwrap_or(60);
714 Err(ApiError::RateLimitExceeded { retry_after })
715 }
716 status if status.is_server_error() => {
717 let message = response
718 .text()
719 .await
720 .unwrap_or_else(|_| "Server error".to_string());
721 Err(ApiError::ServerError {
722 status: status.as_u16(),
723 message,
724 })
725 }
726 status if status.is_success() => {
727 let bytes = response
728 .bytes()
729 .await
730 .map_err(|e| ApiError::InvalidResponse(e.to_string()))?;
731 let slice: &[u8] = if bytes.iter().all(|b| b.is_ascii_whitespace()) {
737 b"null"
738 } else {
739 &bytes
740 };
741 serde_json::from_slice::<T>(slice).map_err(|e| {
742 error!("Failed to parse JSON response: {}", e);
743 ApiError::InvalidResponse(e.to_string())
744 })
745 }
746 _ => {
747 let message = response
748 .text()
749 .await
750 .unwrap_or_else(|_| format!("Unexpected status: {}", status));
751 Err(ApiError::ServerError {
752 status: status.as_u16(),
753 message,
754 })
755 }
756 }
757 })
758 .await?;
759
760 Ok(result)
761 }
762
763 pub fn apply_auth(&self, request: RequestBuilder) -> RequestBuilder {
764 match &self.auth {
765 Some(AuthMethod::Basic { username, token }) => {
766 request.basic_auth(username, Some(token.expose_secret()))
767 }
768 Some(AuthMethod::Bearer { token }) => request.bearer_auth(token.expose_secret()),
769 Some(AuthMethod::GenieKey { api_key }) => request.header(
770 "Authorization",
771 format!("GenieKey {}", api_key.expose_secret()),
772 ),
773 None => request,
774 }
775 }
776
777 pub fn rate_limiter(&self) -> &RateLimiter {
778 &self.rate_limiter
779 }
780}
781
782#[cfg(test)]
783mod tests {
784 use super::*;
785 use wiremock::matchers::{body_string, header, method, path};
786 use wiremock::{Mock, MockServer, ResponseTemplate};
787
788 #[tokio::test]
789 async fn test_403_returns_forbidden() {
790 let server = MockServer::start().await;
791 Mock::given(method("GET"))
792 .and(path("test"))
793 .respond_with(ResponseTemplate::new(403).set_body_string("You do not have access"))
794 .mount(&server)
795 .await;
796
797 let client = ApiClient::new(server.uri()).unwrap();
798 let result: error::Result<serde_json::Value> = client.get("/test").await;
799
800 match result {
801 Err(ApiError::Forbidden { message }) => {
802 assert!(message.contains("You do not have access"));
803 }
804 other => panic!("Expected Forbidden, got: {:?}", other),
805 }
806 }
807
808 #[tokio::test]
809 async fn test_401_returns_authentication_failed() {
810 let server = MockServer::start().await;
811 Mock::given(method("GET"))
812 .and(path("test"))
813 .respond_with(ResponseTemplate::new(401))
814 .mount(&server)
815 .await;
816
817 let client = ApiClient::new(server.uri()).unwrap();
818 let result: error::Result<serde_json::Value> = client.get("/test").await;
819
820 match result {
821 Err(ApiError::AuthenticationFailed { .. }) => {}
822 other => panic!("Expected AuthenticationFailed, got: {:?}", other),
823 }
824 }
825
826 #[tokio::test]
827 async fn test_403_get_text_returns_forbidden() {
828 let server = MockServer::start().await;
829 Mock::given(method("GET"))
830 .and(path("text-endpoint"))
831 .respond_with(ResponseTemplate::new(403).set_body_string("Forbidden resource"))
832 .mount(&server)
833 .await;
834
835 let client = ApiClient::new(server.uri()).unwrap();
836 let result = client.get_text("/text-endpoint").await;
837
838 match result {
839 Err(ApiError::Forbidden { message }) => {
840 assert!(message.contains("Forbidden resource"));
841 }
842 other => panic!("Expected Forbidden, got: {:?}", other),
843 }
844 }
845
846 #[tokio::test]
847 async fn test_403_get_bytes_returns_forbidden() {
848 let server = MockServer::start().await;
849 Mock::given(method("GET"))
850 .and(path("bytes-endpoint"))
851 .respond_with(ResponseTemplate::new(403).set_body_string("Access denied"))
852 .mount(&server)
853 .await;
854
855 let client = ApiClient::new(server.uri()).unwrap();
856 let result = client.get_bytes("/bytes-endpoint").await;
857
858 match result {
859 Err(ApiError::Forbidden { message }) => {
860 assert!(message.contains("Access denied"));
861 }
862 other => panic!("Expected Forbidden, got: {:?}", other),
863 }
864 }
865
866 #[tokio::test]
869 async fn test_204_no_content_put_succeeds() {
870 let server = MockServer::start().await;
871 Mock::given(method("PUT"))
872 .and(path("issue/AEA-1"))
873 .respond_with(ResponseTemplate::new(204))
874 .mount(&server)
875 .await;
876
877 let client = ApiClient::new(server.uri()).unwrap();
878 let result: error::Result<serde_json::Value> = client
879 .put("/issue/AEA-1", &serde_json::json!({"fields": {}}))
880 .await;
881
882 match result {
883 Ok(serde_json::Value::Null) => {}
884 other => panic!("Expected Ok(Null) for 204, got: {:?}", other),
885 }
886 }
887
888 #[tokio::test]
890 async fn test_200_empty_body_succeeds() {
891 let server = MockServer::start().await;
892 Mock::given(method("POST"))
893 .and(path("transitions"))
894 .respond_with(ResponseTemplate::new(200).set_body_string(" \n"))
895 .mount(&server)
896 .await;
897
898 let client = ApiClient::new(server.uri()).unwrap();
899 let result: error::Result<serde_json::Value> =
900 client.post("/transitions", &serde_json::json!({})).await;
901
902 match result {
903 Ok(serde_json::Value::Null) => {}
904 other => panic!("Expected Ok(Null) for empty 200, got: {:?}", other),
905 }
906 }
907
908 #[tokio::test]
910 async fn test_200_json_body_still_parses() {
911 let server = MockServer::start().await;
912 Mock::given(method("GET"))
913 .and(path("issue/AEA-1"))
914 .respond_with(
915 ResponseTemplate::new(200).set_body_json(serde_json::json!({"key": "AEA-1"})),
916 )
917 .mount(&server)
918 .await;
919
920 let client = ApiClient::new(server.uri()).unwrap();
921 let result: serde_json::Value = client.get("/issue/AEA-1").await.unwrap();
922 assert_eq!(result["key"], "AEA-1");
923 }
924
925 #[tokio::test]
932 async fn test_request_raw_surfaces_non_2xx_without_erroring() {
933 let server = MockServer::start().await;
934 Mock::given(method("GET"))
935 .and(path("/rest/api/3/issue/NOPE-1"))
936 .respond_with(
937 ResponseTemplate::new(404)
938 .set_body_json(serde_json::json!({"errorMessages": ["Issue does not exist"]})),
939 )
940 .mount(&server)
941 .await;
942
943 let client = ApiClient::new(server.uri()).unwrap();
944 let response = client
945 .request_raw(RawRequest {
946 method: Method::GET,
947 path: "/rest/api/3/issue/NOPE-1",
948 headers: HeaderMap::new(),
949 body: None,
950 timeout: None,
951 })
952 .await
953 .unwrap();
954
955 assert_eq!(response.status, 404);
956 assert!(!response.is_success());
957 assert!(response
958 .header("Content-Type")
959 .unwrap()
960 .contains("application/json"));
961 assert!(String::from_utf8_lossy(&response.body).contains("Issue does not exist"));
962 }
963
964 #[tokio::test]
965 async fn test_request_raw_applies_headers_and_body() {
966 let server = MockServer::start().await;
967 Mock::given(method("POST"))
968 .and(path("/rest/api/3/issue"))
969 .and(header("X-Atlassian-Token", "no-check"))
970 .and(body_string("{\"fields\":{}}"))
971 .respond_with(
972 ResponseTemplate::new(201).set_body_json(serde_json::json!({"key": "A-1"})),
973 )
974 .mount(&server)
975 .await;
976
977 let mut headers = HeaderMap::new();
978 headers.insert("X-Atlassian-Token", "no-check".parse().unwrap());
979
980 let client = ApiClient::new(server.uri()).unwrap();
981 let response = client
982 .request_raw(RawRequest {
983 method: Method::POST,
984 path: "/rest/api/3/issue",
985 headers,
986 body: Some(b"{\"fields\":{}}"),
987 timeout: None,
988 })
989 .await
990 .unwrap();
991
992 assert_eq!(response.status, 201);
993 }
994
995 #[tokio::test]
996 async fn test_request_raw_retries_5xx_for_get() {
997 let server = MockServer::start().await;
998 Mock::given(method("GET"))
999 .and(path("/flaky"))
1000 .respond_with(ResponseTemplate::new(500))
1001 .expect(3)
1002 .mount(&server)
1003 .await;
1004
1005 let client = ApiClient::new(server.uri())
1006 .unwrap()
1007 .with_retry_config(RetryConfig {
1008 initial_interval: Duration::from_millis(1),
1009 ..RetryConfig::default()
1010 });
1011 let response = client
1012 .request_raw(RawRequest {
1013 method: Method::GET,
1014 path: "/flaky",
1015 headers: HeaderMap::new(),
1016 body: None,
1017 timeout: None,
1018 })
1019 .await
1020 .unwrap();
1021
1022 assert_eq!(response.status, 500);
1023 }
1024
1025 #[tokio::test]
1028 async fn test_request_raw_never_retries_post() {
1029 let server = MockServer::start().await;
1030 Mock::given(method("POST"))
1031 .and(path("/create"))
1032 .respond_with(ResponseTemplate::new(503))
1033 .expect(1)
1034 .mount(&server)
1035 .await;
1036
1037 let client = ApiClient::new(server.uri())
1038 .unwrap()
1039 .with_retry_config(RetryConfig {
1040 initial_interval: Duration::from_millis(1),
1041 ..RetryConfig::default()
1042 });
1043 let response = client
1044 .request_raw(RawRequest {
1045 method: Method::POST,
1046 path: "/create",
1047 headers: HeaderMap::new(),
1048 body: Some(b"{}"),
1049 timeout: None,
1050 })
1051 .await
1052 .unwrap();
1053
1054 assert_eq!(response.status, 503);
1055 }
1056
1057 #[tokio::test]
1058 async fn test_request_raw_rejects_cross_host_path() {
1059 let server = MockServer::start().await;
1060 Mock::given(method("GET"))
1061 .respond_with(ResponseTemplate::new(200))
1062 .expect(0)
1063 .mount(&server)
1064 .await;
1065
1066 let client = ApiClient::new(server.uri()).unwrap();
1067 let err = client
1068 .request_raw(RawRequest {
1069 method: Method::GET,
1070 path: "https://evil.example.com/steal",
1071 headers: HeaderMap::new(),
1072 body: None,
1073 timeout: None,
1074 })
1075 .await
1076 .unwrap_err();
1077
1078 assert!(matches!(err, ApiError::InvalidUrl(_)), "got {err:?}");
1079 }
1080
1081 #[test]
1082 fn test_resolve_url_enforces_same_origin() {
1083 let client = ApiClient::new("https://site.atlassian.net").unwrap();
1084
1085 assert_eq!(
1086 client.resolve_url("/rest/api/3/myself").unwrap().as_str(),
1087 "https://site.atlassian.net/rest/api/3/myself"
1088 );
1089 assert_eq!(
1091 client.resolve_url("rest/api/3/myself").unwrap().as_str(),
1092 "https://site.atlassian.net/rest/api/3/myself"
1093 );
1094 for bad in [
1096 "https://evil.example.com/x",
1097 "http://site.atlassian.net/x",
1098 "https://site.atlassian.net@evil.example.com/",
1099 "//evil.example.com/x",
1100 ] {
1101 let resolved = client.resolve_url(bad);
1102 match resolved {
1103 Err(_) => {}
1104 Ok(url) => assert_eq!(url.host_str(), Some("site.atlassian.net"), "{bad}"),
1107 }
1108 }
1109 }
1110
1111 #[test]
1114 fn test_resolve_url_keeps_the_base_path() {
1115 let client = ApiClient::new("https://api.atlassian.com/ex/jira/cloud-id").unwrap();
1116
1117 assert_eq!(
1118 client.base_url(),
1119 "https://api.atlassian.com/ex/jira/cloud-id/"
1120 );
1121 assert_eq!(
1122 client.resolve_url("/rest/api/3/myself").unwrap().as_str(),
1123 "https://api.atlassian.com/ex/jira/cloud-id/rest/api/3/myself"
1124 );
1125 assert_eq!(
1126 client.resolve_url("rest/api/3/myself").unwrap().as_str(),
1127 "https://api.atlassian.com/ex/jira/cloud-id/rest/api/3/myself"
1128 );
1129
1130 let client = ApiClient::new("https://api.atlassian.com/ex/jira/cloud-id/").unwrap();
1132
1133 assert_eq!(
1134 client.base_url(),
1135 "https://api.atlassian.com/ex/jira/cloud-id/"
1136 );
1137 assert_eq!(
1138 client.resolve_url("/rest/api/3/myself").unwrap().as_str(),
1139 "https://api.atlassian.com/ex/jira/cloud-id/rest/api/3/myself"
1140 );
1141 assert_eq!(
1142 client.resolve_url("rest/api/3/myself").unwrap().as_str(),
1143 "https://api.atlassian.com/ex/jira/cloud-id/rest/api/3/myself"
1144 );
1145 }
1146
1147 #[test]
1152 fn test_resolve_url_keeps_a_context_path() {
1153 let client = ApiClient::new("https://example.com/bamboo").unwrap();
1154
1155 assert_eq!(
1156 client
1157 .resolve_url("/rest/api/latest/plan")
1158 .unwrap()
1159 .as_str(),
1160 "https://example.com/bamboo/rest/api/latest/plan"
1161 );
1162 }
1163
1164 #[test]
1168 fn test_normalisation_does_not_move_existing_product_urls() {
1169 for (base, path, expected) in [
1170 (
1171 "https://x.atlassian.net",
1172 "/rest/api/3/myself",
1173 "https://x.atlassian.net/rest/api/3/myself",
1174 ),
1175 (
1176 "https://x.atlassian.net",
1177 "/wiki/download/attachments/1/f.png?version=1",
1178 "https://x.atlassian.net/wiki/download/attachments/1/f.png?version=1",
1179 ),
1180 (
1181 "https://api.bitbucket.org",
1182 "/2.0/repositories/w/r",
1183 "https://api.bitbucket.org/2.0/repositories/w/r",
1184 ),
1185 (
1188 "https://api.opsgenie.com/v2/",
1189 "alerts/123",
1190 "https://api.opsgenie.com/v2/alerts/123",
1191 ),
1192 ] {
1193 let client = ApiClient::new(base).unwrap();
1194 assert_eq!(
1195 client.resolve_url(path).unwrap().as_str(),
1196 expected,
1197 "{base} + {path}"
1198 );
1199 }
1200 }
1201
1202 #[tokio::test]
1205 async fn test_request_raw_rejects_a_different_port_on_the_same_host() {
1206 let victim = MockServer::start().await;
1207 Mock::given(method("GET"))
1208 .respond_with(ResponseTemplate::new(200).set_body_string("secrets"))
1209 .expect(0)
1210 .mount(&victim)
1211 .await;
1212
1213 let server = MockServer::start().await;
1214 let client = ApiClient::new(server.uri()).unwrap();
1215 let err = client
1216 .request_raw(RawRequest {
1217 method: Method::GET,
1218 path: &format!("{}/steal", victim.uri()),
1219 headers: HeaderMap::new(),
1220 body: None,
1221 timeout: None,
1222 })
1223 .await
1224 .unwrap_err();
1225
1226 assert!(matches!(err, ApiError::InvalidUrl(_)), "got {err:?}");
1227 }
1228
1229 #[tokio::test]
1233 async fn test_request_raw_does_not_follow_a_cross_origin_redirect() {
1234 let evil = MockServer::start().await;
1235 Mock::given(method("POST"))
1236 .respond_with(ResponseTemplate::new(200).set_body_string("pwned"))
1237 .expect(0)
1238 .mount(&evil)
1239 .await;
1240
1241 let server = MockServer::start().await;
1242 Mock::given(method("POST"))
1243 .and(path("/rest/api/3/bounce"))
1244 .respond_with(
1245 ResponseTemplate::new(307)
1246 .insert_header("location", format!("{}/steal", evil.uri()).as_str()),
1247 )
1248 .mount(&server)
1249 .await;
1250
1251 let client = ApiClient::new(server.uri())
1252 .unwrap()
1253 .with_basic_auth("dev@example.com", "token");
1254 let response = client
1255 .request_raw(RawRequest {
1256 method: Method::POST,
1257 path: "/rest/api/3/bounce",
1258 headers: HeaderMap::new(),
1259 body: Some(b"{}"),
1260 timeout: None,
1261 })
1262 .await
1263 .unwrap();
1264
1265 assert_eq!(response.status, 307);
1266 assert!(response.header("location").unwrap().contains("/steal"));
1267 assert_ne!(response.body, b"pwned".to_vec());
1268 }
1269
1270 #[tokio::test]
1272 async fn test_request_raw_follows_a_same_origin_redirect() {
1273 let server = MockServer::start().await;
1274 Mock::given(method("GET"))
1275 .and(path("/from"))
1276 .respond_with(ResponseTemplate::new(302).insert_header("location", "/to"))
1277 .mount(&server)
1278 .await;
1279 Mock::given(method("GET"))
1280 .and(path("/to"))
1281 .respond_with(ResponseTemplate::new(200).set_body_string("arrived"))
1282 .mount(&server)
1283 .await;
1284
1285 let client = ApiClient::new(server.uri()).unwrap();
1286 let response = client
1287 .request_raw(RawRequest {
1288 method: Method::GET,
1289 path: "/from",
1290 headers: HeaderMap::new(),
1291 body: None,
1292 timeout: None,
1293 })
1294 .await
1295 .unwrap();
1296
1297 assert_eq!(response.status, 200);
1298 assert_eq!(response.body, b"arrived".to_vec());
1299 }
1300
1301 #[tokio::test]
1304 async fn test_get_bytes_still_follows_cross_host_redirects() {
1305 let media = MockServer::start().await;
1306 Mock::given(method("GET"))
1307 .and(path("/file/binary"))
1308 .respond_with(ResponseTemplate::new(200).set_body_bytes(b"BYTES".to_vec()))
1309 .mount(&media)
1310 .await;
1311
1312 let server = MockServer::start().await;
1313 Mock::given(method("GET"))
1314 .and(path("/content/1"))
1315 .respond_with(
1316 ResponseTemplate::new(302)
1317 .insert_header("location", format!("{}/file/binary", media.uri()).as_str()),
1318 )
1319 .mount(&server)
1320 .await;
1321
1322 let client = ApiClient::new(server.uri()).unwrap();
1323 assert_eq!(client.get_bytes("/content/1").await.unwrap(), b"BYTES");
1324 }
1325
1326 #[test]
1327 fn test_same_origin_compares_scheme_host_and_port() {
1328 let base = Url::parse("https://site.atlassian.net").unwrap();
1329 assert!(same_origin(
1330 &Url::parse("https://site.atlassian.net/x").unwrap(),
1331 &base
1332 ));
1333 assert!(same_origin(
1335 &Url::parse("https://site.atlassian.net:443/x").unwrap(),
1336 &base
1337 ));
1338 for other in [
1339 "https://site.atlassian.net:8443/x",
1340 "http://site.atlassian.net/x",
1341 "https://evil.example.com/x",
1342 ] {
1343 assert!(
1344 !same_origin(&Url::parse(other).unwrap(), &base),
1345 "{other} must not match"
1346 );
1347 }
1348 }
1349}