Skip to main content

bb_cli/api/
mod.rs

1pub mod models;
2
3use crate::credentials::Credentials;
4use crate::error::{BbError, Result};
5use crate::repo::RepoSlug;
6use crate::secret::ExposeSecret;
7use serde::de::DeserializeOwned;
8use serde::{Deserialize, Serialize};
9use std::time::Duration;
10
11pub const DEFAULT_BASE_URL: &str = "https://api.bitbucket.org/2.0";
12const MAX_PAGES: usize = 100;
13const MAX_REDIRECTS: usize = 5;
14
15#[derive(Debug, Deserialize)]
16#[serde(bound(deserialize = "T: Deserialize<'de>"))]
17pub struct Page<T> {
18    #[serde(default)]
19    pub values: Vec<T>,
20    #[serde(default)]
21    pub next: Option<String>,
22    #[serde(default)]
23    pub size: Option<u64>,
24}
25
26pub fn repo_path(slug: &RepoSlug, suffix: &str) -> String {
27    format!("/repositories/{}{}", slug.path(), suffix)
28}
29
30/// Bitbucket answers some endpoints — `/pullrequests/{id}/diff` among them —
31/// with a 302 to another url on the same origin, so redirects have to be
32/// followed or those commands fail outright. They are followed only within the
33/// same origin: the Authorization header is attached to every request this
34/// client makes, and it must never be replayed to another host.
35fn same_origin_redirect_policy() -> reqwest::redirect::Policy {
36    reqwest::redirect::Policy::custom(|attempt| {
37        if attempt.previous().len() > MAX_REDIRECTS {
38            return attempt.stop();
39        }
40        match attempt.previous().last() {
41            Some(previous) if previous.origin() == attempt.url().origin() => attempt.follow(),
42            _ => attempt.stop(),
43        }
44    })
45}
46
47pub struct Client {
48    http: reqwest::Client,
49    base_url: String,
50    auth_header: crate::secret::SecretString,
51}
52
53impl Client {
54    pub fn new(creds: Credentials, base_url: String) -> Result<Self> {
55        let http = reqwest::Client::builder()
56            .redirect(same_origin_redirect_policy())
57            .connect_timeout(Duration::from_secs(10))
58            .timeout(Duration::from_secs(30))
59            .user_agent(concat!("bb-cli/", env!("CARGO_PKG_VERSION")))
60            .build()?;
61
62        Ok(Self {
63            http,
64            base_url: base_url.trim_end_matches('/').to_string(),
65            auth_header: creds.basic_header(),
66        })
67    }
68
69    pub fn from_env(creds: Credentials) -> Result<Self> {
70        let base = std::env::var("BB_API_BASE").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string());
71        Self::new(creds, base)
72    }
73
74    fn url(&self, path_or_url: &str) -> String {
75        if path_or_url.starts_with("http://") || path_or_url.starts_with("https://") {
76            path_or_url.to_string()
77        } else {
78            format!("{}{}", self.base_url, path_or_url)
79        }
80    }
81
82    fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
83        self.http
84            .request(method, self.url(path))
85            .header(
86                reqwest::header::AUTHORIZATION,
87                self.auth_header.expose_secret(),
88            )
89            .header(reqwest::header::ACCEPT, "application/json")
90    }
91
92    /// Turns a non-success response into a `BbError`, preferring the API's own
93    /// error message over the raw body so nothing unexpected is echoed.
94    async fn check(response: reqwest::Response) -> Result<reqwest::Response> {
95        let status = response.status();
96        if status.is_success() {
97            return Ok(response);
98        }
99        match status.as_u16() {
100            401 => return Err(BbError::Auth),
101            403 => {
102                return Err(BbError::Api {
103                    status: 403,
104                    message: "forbidden — the token may lack the required scope".into(),
105                })
106            }
107            404 => return Err(BbError::NotFound),
108            429 => {
109                return Err(BbError::Api {
110                    status: 429,
111                    message: "rate limited by bitbucket — retry shortly".into(),
112                })
113            }
114            _ => {}
115        }
116
117        let code = status.as_u16();
118        let body = response.text().await.unwrap_or_default();
119        let message = serde_json::from_str::<serde_json::Value>(&body)
120            .ok()
121            .and_then(|v| {
122                v.get("error")
123                    .and_then(|e| e.get("message"))
124                    .and_then(|m| m.as_str())
125                    .map(str::to_string)
126            })
127            .unwrap_or_else(|| {
128                status
129                    .canonical_reason()
130                    .unwrap_or("request failed")
131                    .to_string()
132            });
133
134        Err(BbError::Api {
135            status: code,
136            message,
137        })
138    }
139
140    pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
141        let response = Self::check(self.request(reqwest::Method::GET, path).send().await?).await?;
142        Ok(response.json::<T>().await?)
143    }
144
145    pub async fn get_text(&self, path: &str) -> Result<String> {
146        let response = Self::check(self.request(reqwest::Method::GET, path).send().await?).await?;
147        Ok(response.text().await?)
148    }
149
150    pub async fn post_json<T: DeserializeOwned, B: Serialize + ?Sized>(
151        &self,
152        path: &str,
153        body: &B,
154    ) -> Result<T> {
155        let response = Self::check(
156            self.request(reqwest::Method::POST, path)
157                .json(body)
158                .send()
159                .await?,
160        )
161        .await?;
162        Ok(response.json::<T>().await?)
163    }
164
165    pub async fn post_empty(&self, path: &str) -> Result<()> {
166        Self::check(self.request(reqwest::Method::POST, path).send().await?).await?;
167        Ok(())
168    }
169
170    pub async fn put_json<T: DeserializeOwned, B: Serialize + ?Sized>(
171        &self,
172        path: &str,
173        body: &B,
174    ) -> Result<T> {
175        let response = Self::check(
176            self.request(reqwest::Method::PUT, path)
177                .json(body)
178                .send()
179                .await?,
180        )
181        .await?;
182        Ok(response.json::<T>().await?)
183    }
184
185    pub async fn delete(&self, path: &str) -> Result<()> {
186        Self::check(self.request(reqwest::Method::DELETE, path).send().await?).await?;
187        Ok(())
188    }
189
190    pub async fn paginate<T: DeserializeOwned>(&self, path: &str) -> Result<Vec<T>> {
191        let mut collected = Vec::new();
192        let mut next = Some(path.to_string());
193        let mut pages = 0;
194        let mut seen: Vec<String> = Vec::new();
195
196        while let Some(target) = next {
197            if pages >= MAX_PAGES {
198                break;
199            }
200            // A `next` link that repeats an already-fetched url would otherwise
201            // refetch the same page up to MAX_PAGES times and silently return
202            // duplicated values. Compare resolved urls so a relative path and
203            // the absolute url it resolves to are recognized as the same page.
204            let resolved = self.url(&target);
205            if seen.contains(&resolved) {
206                break;
207            }
208            seen.push(resolved);
209
210            let page: Page<T> = self.get_json(&target).await?;
211            collected.extend(page.values);
212            next = page.next;
213            pages += 1;
214        }
215
216        Ok(collected)
217    }
218}