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
30pub fn workspace_path(workspace: &str, suffix: &str) -> String {
31    format!("/workspaces/{}{}", urlencoding::encode(workspace), suffix)
32}
33
34pub fn workspace_repos_path(workspace: &str, suffix: &str) -> String {
35    format!("/repositories/{}{}", urlencoding::encode(workspace), suffix)
36}
37
38/// Bitbucket answers some endpoints — `/pullrequests/{id}/diff` among them —
39/// with a 302 to another url on the same origin, so redirects have to be
40/// followed or those commands fail outright. They are followed only within the
41/// same origin: the Authorization header is attached to every request this
42/// client makes, and it must never be replayed to another host.
43fn same_origin_redirect_policy() -> reqwest::redirect::Policy {
44    reqwest::redirect::Policy::custom(|attempt| {
45        if attempt.previous().len() > MAX_REDIRECTS {
46            return attempt.stop();
47        }
48        match attempt.previous().last() {
49            Some(previous) if previous.origin() == attempt.url().origin() => attempt.follow(),
50            _ => attempt.stop(),
51        }
52    })
53}
54
55pub struct Client {
56    http: reqwest::Client,
57    base_url: String,
58    auth_header: crate::secret::SecretString,
59}
60
61impl Client {
62    pub fn new(creds: Credentials, base_url: String) -> Result<Self> {
63        let http = reqwest::Client::builder()
64            .redirect(same_origin_redirect_policy())
65            .connect_timeout(Duration::from_secs(10))
66            .timeout(Duration::from_secs(30))
67            .user_agent(concat!("bb-cli/", env!("CARGO_PKG_VERSION")))
68            .build()?;
69
70        Ok(Self {
71            http,
72            base_url: base_url.trim_end_matches('/').to_string(),
73            auth_header: creds.basic_header(),
74        })
75    }
76
77    pub fn from_env(creds: Credentials) -> Result<Self> {
78        let base = std::env::var("BB_API_BASE").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string());
79        Self::new(creds, base)
80    }
81
82    fn url(&self, path_or_url: &str) -> String {
83        if path_or_url.starts_with("http://") || path_or_url.starts_with("https://") {
84            path_or_url.to_string()
85        } else {
86            format!("{}{}", self.base_url, path_or_url)
87        }
88    }
89
90    fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
91        self.http
92            .request(method, self.url(path))
93            .header(
94                reqwest::header::AUTHORIZATION,
95                self.auth_header.expose_secret(),
96            )
97            .header(reqwest::header::ACCEPT, "application/json")
98    }
99
100    /// Turns a non-success response into a `BbError`, preferring the API's own
101    /// error message over the raw body so nothing unexpected is echoed.
102    async fn check(response: reqwest::Response) -> Result<reqwest::Response> {
103        let status = response.status();
104        if status.is_success() {
105            return Ok(response);
106        }
107        match status.as_u16() {
108            401 => return Err(BbError::Auth),
109            404 => return Err(BbError::NotFound),
110            429 => {
111                return Err(BbError::Api {
112                    status: 429,
113                    message: "rate limited by bitbucket — retry shortly".into(),
114                })
115            }
116            _ => {}
117        }
118
119        let code = status.as_u16();
120        let body = response.text().await.unwrap_or_default();
121        let api_message = serde_json::from_str::<serde_json::Value>(&body)
122            .ok()
123            .and_then(|v| {
124                v.get("error")
125                    .and_then(|e| e.get("message"))
126                    .and_then(|m| m.as_str())
127                    .map(str::to_string)
128            });
129
130        let message = if code == 403 {
131            match api_message {
132                Some(api_message) => format!(
133                    "forbidden — {api_message} — the token may lack the required scope; see the scope table in the README"
134                ),
135                None => "forbidden — the token may lack the required scope; see the scope table in the README".into(),
136            }
137        } else {
138            api_message.unwrap_or_else(|| {
139                status
140                    .canonical_reason()
141                    .unwrap_or("request failed")
142                    .to_string()
143            })
144        };
145
146        Err(BbError::Api {
147            status: code,
148            message,
149        })
150    }
151
152    pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
153        let response = Self::check(self.request(reqwest::Method::GET, path).send().await?).await?;
154        Ok(response.json::<T>().await?)
155    }
156
157    pub async fn get_text(&self, path: &str) -> Result<String> {
158        let response = Self::check(self.request(reqwest::Method::GET, path).send().await?).await?;
159        Ok(response.text().await?)
160    }
161
162    pub async fn post_json<T: DeserializeOwned, B: Serialize + ?Sized>(
163        &self,
164        path: &str,
165        body: &B,
166    ) -> Result<T> {
167        let response = Self::check(
168            self.request(reqwest::Method::POST, path)
169                .json(body)
170                .send()
171                .await?,
172        )
173        .await?;
174        Ok(response.json::<T>().await?)
175    }
176
177    pub async fn post_empty(&self, path: &str) -> Result<()> {
178        Self::check(self.request(reqwest::Method::POST, path).send().await?).await?;
179        Ok(())
180    }
181
182    pub async fn put_json<T: DeserializeOwned, B: Serialize + ?Sized>(
183        &self,
184        path: &str,
185        body: &B,
186    ) -> Result<T> {
187        let response = Self::check(
188            self.request(reqwest::Method::PUT, path)
189                .json(body)
190                .send()
191                .await?,
192        )
193        .await?;
194        Ok(response.json::<T>().await?)
195    }
196
197    pub async fn delete(&self, path: &str) -> Result<()> {
198        Self::check(self.request(reqwest::Method::DELETE, path).send().await?).await?;
199        Ok(())
200    }
201
202    pub async fn paginate<T: DeserializeOwned>(&self, path: &str) -> Result<Vec<T>> {
203        let mut collected = Vec::new();
204        let mut next = Some(path.to_string());
205        let mut pages = 0;
206        let mut seen: Vec<String> = Vec::new();
207
208        while let Some(target) = next {
209            if pages >= MAX_PAGES {
210                break;
211            }
212            // A `next` link that repeats an already-fetched url would otherwise
213            // refetch the same page up to MAX_PAGES times and silently return
214            // duplicated values. Compare resolved urls so a relative path and
215            // the absolute url it resolves to are recognized as the same page.
216            let resolved = self.url(&target);
217            if seen.contains(&resolved) {
218                break;
219            }
220            seen.push(resolved);
221
222            let page: Page<T> = self.get_json(&target).await?;
223            collected.extend(page.values);
224            next = page.next;
225            pages += 1;
226        }
227
228        Ok(collected)
229    }
230}
231
232#[cfg(test)]
233#[allow(clippy::unwrap_used)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn workspace_path_encodes_the_slug_exactly_once() {
239        assert_eq!(
240            workspace_path("acme", "/projects"),
241            "/workspaces/acme/projects"
242        );
243        assert_eq!(
244            workspace_path("a c/me", "/projects"),
245            "/workspaces/a%20c%2Fme/projects"
246        );
247    }
248
249    #[test]
250    fn workspace_repos_path_encodes_the_slug_exactly_once() {
251        assert_eq!(workspace_repos_path("acme", ""), "/repositories/acme");
252        assert_eq!(
253            workspace_repos_path("a c/me", "?pagelen=100"),
254            "/repositories/a%20c%2Fme?pagelen=100"
255        );
256    }
257}