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/// Make a base URL end with `/`.
66///
67/// `Url::join` drops the base's last path segment unless the base ends with
68/// `/`. Idempotent.
69pub 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
81/// The `Retry-After` delay in seconds, when the server sent one. The HTTP-date
82/// form is ignored; Atlassian sends seconds.
83fn 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
95/// An arbitrary request for [`ApiClient::request_raw`].
96pub struct RawRequest<'a> {
97    pub method: Method,
98    /// Path (and optional query) relative to the client's base URL.
99    pub path: &'a str,
100    pub headers: HeaderMap,
101    pub body: Option<&'a [u8]>,
102    /// Overrides the client-wide 30s timeout for this request only.
103    pub timeout: Option<Duration>,
104}
105
106/// A response with no status-to-error mapping applied.
107#[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    /// Case-insensitive header lookup. Returns the first match.
120    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    /// Same-origin-only redirect policy; used by `request_raw`.
132    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        // Enforce HTTPS for security (prevent accidental credential leaks over HTTP)
144        // Allow HTTP only for localhost/127.0.0.1 (for testing)
145        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        // `request_raw` sends user-chosen methods, bodies and headers, so it gets
167        // a client that refuses to leave the profile's origin. The default
168        // policy would follow a `Location` anywhere: reqwest strips
169        // `Authorization` cross-host, but 307/308 replay the body and custom
170        // `-H` headers are not stripped. A stopped redirect is returned to the
171        // caller as the 3xx itself, which is the transparent answer for a
172        // passthrough. The normal `client` keeps following redirects, because
173        // attachment downloads depend on the cross-host hop to Atlassian's
174        // media host.
175        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    /// Safely join a path to the base URL, ensuring the origin remains unchanged
202    /// to prevent SSRF attacks.
203    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    /// Returns a reference to the underlying HTTP client for raw requests (e.g., multipart uploads).
254    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    /// DELETE that expects 204 No Content (no response body).
292    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    /// Get plain text content from an endpoint.
377    /// Sets Accept: text/plain; charset=utf-8 header.
378    /// Includes retry logic and rate limiting.
379    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    /// Resolve `path` against the base URL, applying the same-origin (SSRF)
480    /// check used by every request. Public so callers can validate or preview a
481    /// path without sending anything.
482    pub fn resolve_url(&self, path: &str) -> Result<Url> {
483        self.safe_join(path)
484    }
485
486    /// Send an arbitrary request and return the status, headers and body bytes.
487    ///
488    /// Unlike [`ApiClient::request`], a non-2xx status is returned as
489    /// `Ok(RawResponse)` rather than mapped to an [`ApiError`], so callers can
490    /// surface the API's own error body. Only transport failures and URL
491    /// validation produce `Err`. Same-origin validation, auth and rate limiting
492    /// still apply.
493    ///
494    /// Retries on 429/5xx are limited to idempotent methods. `request` retries
495    /// POSTs, which can double-create; a raw passthrough must not inherit that.
496    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        // retry_with_backoff cannot be used here: its closure must signal a
510        // retryable outcome as Err, which would discard the RawResponse we have
511        // to return on the final attempt.
512        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                    // A 429 says how long to wait; obey it rather than racing
536                    // back in after a short exponential sleep.
537                    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    /// Get binary content from an endpoint.
574    /// Includes retry logic and rate limiting.
575    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                    // Successful responses with an empty (or whitespace-only) body,
732                    // e.g. HTTP 204 No Content from Jira update/transition/assign and
733                    // most DELETEs, are treated as JSON `null`. Callers that discard
734                    // the body (`let _: Value`) then succeed instead of failing to
735                    // parse an empty body as JSON.
736                    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    // Regression for #45: a successful PUT/POST returning HTTP 204 No Content (empty
867    // body) must not fail JSON parsing. Callers discard the body as `Value`.
868    #[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    // A 200 with an empty/whitespace-only body is also treated as null.
889    #[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    // A non-empty JSON body on success still parses normally.
909    #[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    // -----------------------------------------------------------------------
926    // request_raw
927    // -----------------------------------------------------------------------
928
929    /// The point of the raw path: a non-2xx status is data, not an error, so the
930    /// API's own error body survives instead of being replaced by ApiError.
931    #[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    /// Replaying a POST can double-create. `request` does retry POSTs; the raw
1026    /// path deliberately does not inherit that.
1027    #[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        // Relative paths work with or without the leading slash.
1090        assert_eq!(
1091            client.resolve_url("rest/api/3/myself").unwrap().as_str(),
1092            "https://site.atlassian.net/rest/api/3/myself"
1093        );
1094        // Other hosts, scheme downgrades and userinfo tricks are all rejected.
1095        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                // A protocol-relative path is not treated as a host by `join`;
1105                // pin the behaviour so a future change cannot silently open it up.
1106                Ok(url) => assert_eq!(url.host_str(), Some("site.atlassian.net"), "{bad}"),
1107            }
1108        }
1109    }
1110
1111    /// Regression: a base URL carrying a path lost its last segment, so the
1112    /// API-gateway form used by scoped API tokens dropped the cloud id.
1113    #[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        // A base written with the trailing slash resolves the same way.
1131        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    /// The same fix covers a self-hosted product behind a context path, which is
1148    /// the ordinary way Bamboo is deployed. Before this, `/rest/api/latest/plan`
1149    /// against `https://example.com/bamboo` resolved to `https://example.com/rest/...`
1150    /// and 404'd.
1151    #[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    /// Guard against the fix shifting any URL a working profile already resolves.
1165    /// Every shape below is byte-identical before and after normalisation; only
1166    /// the previously broken path-bearing bases move.
1167    #[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            // Opsgenie's base already carries a path and already ends in a
1186            // slash, and its request paths are relative, so it is untouched.
1187            (
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    /// Regression: comparing scheme and host but not port let any other port on
1203    /// the same host receive the profile's credentials.
1204    #[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    /// `safe_join` only sees the first URL, so a same-origin endpoint could
1230    /// otherwise bounce a write-capable request anywhere. The raw client stops
1231    /// at the redirect and hands the 3xx back instead of following it.
1232    #[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    /// Same-origin redirects are still followed, so ordinary endpoints work.
1271    #[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    /// Attachment downloads depend on the cross-host hop to the media host, so
1302    /// the ordinary client must keep following redirects.
1303    #[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        // 443 is the known default for https, so an explicit port still matches.
1334        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}