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
38fn 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 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 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}