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 .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 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 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}