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