Skip to main content

atlassian_cli_api/
lib.rs

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
54/// Compare two URLs by full origin: scheme, host **and port**.
55///
56/// Port matters. Comparing scheme and host alone lets `https://site:8443/x`
57/// through on a `https://site` profile, and on a localhost profile it lets any
58/// other local port receive the profile's credentials.
59fn 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
65/// The `Retry-After` delay in seconds, when the server sent one. The HTTP-date
66/// form is ignored; Atlassian sends seconds.
67fn 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
79/// An arbitrary request for [`ApiClient::request_raw`].
80pub struct RawRequest<'a> {
81    pub method: Method,
82    /// Path (and optional query) relative to the client's base URL.
83    pub path: &'a str,
84    pub headers: HeaderMap,
85    pub body: Option<&'a [u8]>,
86    /// Overrides the client-wide 30s timeout for this request only.
87    pub timeout: Option<Duration>,
88}
89
90/// A response with no status-to-error mapping applied.
91#[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    /// Case-insensitive header lookup. Returns the first match.
104    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    /// Same-origin-only redirect policy; used by `request_raw`.
116    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        // Enforce HTTPS for security (prevent accidental credential leaks over HTTP)
128        // Allow HTTP only for localhost/127.0.0.1 (for testing)
129        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        // `request_raw` sends user-chosen methods, bodies and headers, so it gets
149        // a client that refuses to leave the profile's origin. The default
150        // policy would follow a `Location` anywhere: reqwest strips
151        // `Authorization` cross-host, but 307/308 replay the body and custom
152        // `-H` headers are not stripped. A stopped redirect is returned to the
153        // caller as the 3xx itself, which is the transparent answer for a
154        // passthrough. The normal `client` keeps following redirects, because
155        // attachment downloads depend on the cross-host hop to Atlassian's
156        // media host.
157        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    /// Safely join a path to the base URL, ensuring the origin remains unchanged
184    /// to prevent SSRF attacks.
185    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    /// Returns a reference to the underlying HTTP client for raw requests (e.g., multipart uploads).
236    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    /// DELETE that expects 204 No Content (no response body).
274    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    /// Get plain text content from an endpoint.
359    /// Sets Accept: text/plain; charset=utf-8 header.
360    /// Includes retry logic and rate limiting.
361    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    /// Resolve `path` against the base URL, applying the same-origin (SSRF)
462    /// check used by every request. Public so callers can validate or preview a
463    /// path without sending anything.
464    pub fn resolve_url(&self, path: &str) -> Result<Url> {
465        self.safe_join(path)
466    }
467
468    /// Send an arbitrary request and return the status, headers and body bytes.
469    ///
470    /// Unlike [`ApiClient::request`], a non-2xx status is returned as
471    /// `Ok(RawResponse)` rather than mapped to an [`ApiError`], so callers can
472    /// surface the API's own error body. Only transport failures and URL
473    /// validation produce `Err`. Same-origin validation, auth and rate limiting
474    /// still apply.
475    ///
476    /// Retries on 429/5xx are limited to idempotent methods. `request` retries
477    /// POSTs, which can double-create; a raw passthrough must not inherit that.
478    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        // retry_with_backoff cannot be used here: its closure must signal a
492        // retryable outcome as Err, which would discard the RawResponse we have
493        // to return on the final attempt.
494        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                    // A 429 says how long to wait; obey it rather than racing
518                    // back in after a short exponential sleep.
519                    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    /// Get binary content from an endpoint.
556    /// Includes retry logic and rate limiting.
557    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                    // Successful responses with an empty (or whitespace-only) body,
714                    // e.g. HTTP 204 No Content from Jira update/transition/assign and
715                    // most DELETEs, are treated as JSON `null`. Callers that discard
716                    // the body (`let _: Value`) then succeed instead of failing to
717                    // parse an empty body as JSON.
718                    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    // Regression for #45: a successful PUT/POST returning HTTP 204 No Content (empty
849    // body) must not fail JSON parsing. Callers discard the body as `Value`.
850    #[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    // A 200 with an empty/whitespace-only body is also treated as null.
871    #[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    // A non-empty JSON body on success still parses normally.
891    #[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    // -----------------------------------------------------------------------
908    // request_raw
909    // -----------------------------------------------------------------------
910
911    /// The point of the raw path: a non-2xx status is data, not an error, so the
912    /// API's own error body survives instead of being replaced by ApiError.
913    #[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    /// Replaying a POST can double-create. `request` does retry POSTs; the raw
1008    /// path deliberately does not inherit that.
1009    #[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        // Relative paths work with or without the leading slash.
1072        assert_eq!(
1073            client.resolve_url("rest/api/3/myself").unwrap().as_str(),
1074            "https://site.atlassian.net/rest/api/3/myself"
1075        );
1076        // Other hosts, scheme downgrades and userinfo tricks are all rejected.
1077        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                // A protocol-relative path is not treated as a host by `join`;
1087                // pin the behaviour so a future change cannot silently open it up.
1088                Ok(url) => assert_eq!(url.host_str(), Some("site.atlassian.net"), "{bad}"),
1089            }
1090        }
1091    }
1092
1093    /// Regression: comparing scheme and host but not port let any other port on
1094    /// the same host receive the profile's credentials.
1095    #[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    /// `safe_join` only sees the first URL, so a same-origin endpoint could
1121    /// otherwise bounce a write-capable request anywhere. The raw client stops
1122    /// at the redirect and hands the 3xx back instead of following it.
1123    #[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    /// Same-origin redirects are still followed, so ordinary endpoints work.
1162    #[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    /// Attachment downloads depend on the cross-host hop to the media host, so
1193    /// the ordinary client must keep following redirects.
1194    #[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        // 443 is the known default for https, so an explicit port still matches.
1225        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}