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