1use rand::Rng;
2use reqwest::{multipart, Client, Method, StatusCode};
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::path::Path;
6use std::time::Duration;
7use url::Url;
8use tokio::fs;
9use tokio::io::AsyncWriteExt;
10use tokio::time::sleep;
11
12#[derive(Debug, Deserialize, Serialize)]
14pub struct PagedList {
15 pub results: Vec<Value>,
16 pub count: i64,
17 pub next: Option<String>,
18 pub previous: Option<String>,
19}
20
21#[derive(Debug)]
23pub struct BappApiClient {
24 pub host: String,
25 pub tenant: Option<String>,
26 pub app: String,
27 auth_header: Option<String>,
28 user_agent: Option<String>,
29 max_retries: usize,
30 client: Client,
31}
32
33impl BappApiClient {
34 pub fn new() -> Self {
36 Self {
37 host: "https://panel.bapp.ro/api".to_string(),
38 tenant: None,
39 app: "account".to_string(),
40 auth_header: None,
41 user_agent: None,
42 max_retries: 3,
43 client: Client::builder()
44 .timeout(Duration::from_secs(30))
45 .build()
46 .expect("failed to build HTTP client"),
47 }
48 }
49
50 pub fn with_host(mut self, host: &str) -> Self {
52 self.host = host.trim_end_matches('/').to_string();
53 self
54 }
55
56 pub fn with_bearer(mut self, token: &str) -> Self {
58 self.auth_header = Some(format!("Bearer {}", token));
59 self
60 }
61
62 pub fn with_token(mut self, token: &str) -> Self {
64 self.auth_header = Some(format!("Token {}", token));
65 self
66 }
67
68 pub fn with_tenant(mut self, tenant: &str) -> Self {
70 self.tenant = Some(tenant.to_string());
71 self
72 }
73
74 pub fn with_app(mut self, app: &str) -> Self {
76 self.app = app.to_string();
77 self
78 }
79
80 pub fn with_user_agent(mut self, ua: &str) -> Self {
82 self.user_agent = Some(ua.to_string());
83 self
84 }
85
86 pub fn with_timeout(mut self, timeout: Duration) -> Self {
88 self.client = Client::builder()
89 .timeout(timeout)
90 .build()
91 .expect("failed to build HTTP client");
92 self
93 }
94
95 pub fn with_max_retries(mut self, n: usize) -> Self {
97 self.max_retries = n;
98 self
99 }
100
101 fn build_request(
102 &self,
103 method: Method,
104 path: &str,
105 params: Option<&[(&str, &str)]>,
106 extra_headers: Option<&[(&str, &str)]>,
107 ) -> reqwest::RequestBuilder {
108 let url = format!("{}{}", self.host, path);
109 let mut req = self.client.request(method, &url);
110 if let Some(p) = params {
111 req = req.query(p);
112 }
113 if let Some(ua) = &self.user_agent {
114 req = req.header("User-Agent", ua);
115 }
116 if let Some(auth) = &self.auth_header {
117 req = req.header("Authorization", auth);
118 }
119 if let Some(t) = &self.tenant {
120 req = req.header("x-tenant-id", t);
121 }
122 req = req.header("x-app-slug", &self.app);
123 if let Some(extra) = extra_headers {
124 for (k, v) in extra {
125 req = req.header(*k, *v);
126 }
127 }
128 req
129 }
130
131 async fn request(
132 &self,
133 method: Method,
134 path: &str,
135 params: Option<&[(&str, &str)]>,
136 body: Option<&Value>,
137 extra_headers: Option<&[(&str, &str)]>,
138 ) -> Result<Option<Value>, reqwest::Error> {
139 let mut rng = rand::rng();
140 let mut last_err = None;
141
142 for attempt in 0..=self.max_retries {
143 let mut req = self.build_request(method.clone(), path, params, extra_headers);
144 if let Some(b) = body {
145 req = req.json(b);
146 }
147
148 match req.send().await {
149 Ok(resp) => {
150 let status = resp.status();
151 if (status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error())
152 && attempt < self.max_retries
153 {
154 let backoff = f64::min(
155 2f64.powi(attempt as i32) + rng.random::<f64>(),
156 10.0,
157 );
158 sleep(Duration::from_secs_f64(backoff)).await;
159 continue;
160 }
161 let resp = resp.error_for_status()?;
162 if resp.status() == StatusCode::NO_CONTENT {
163 return Ok(None);
164 }
165 let data = resp.json::<Value>().await?;
166 return Ok(Some(data));
167 }
168 Err(e) => {
169 last_err = Some(e);
170 if attempt < self.max_retries {
171 let backoff = f64::min(
172 2f64.powi(attempt as i32) + rng.random::<f64>(),
173 10.0,
174 );
175 sleep(Duration::from_secs_f64(backoff)).await;
176 continue;
177 }
178 }
179 }
180 }
181 Err(last_err.unwrap())
182 }
183
184 pub async fn request_multipart(
187 &self,
188 method: Method,
189 path: &str,
190 fields: &[(&str, &str)],
191 files: &[(&str, &str)],
192 ) -> Result<Option<Value>, reqwest::Error> {
193 let req = self.build_request(method, path, None, None);
194 let mut form = multipart::Form::new();
195 for (k, v) in fields {
196 form = form.text(k.to_string(), v.to_string());
197 }
198 for (field, file_path) in files {
199 let path = Path::new(file_path);
200 let filename = path.file_name().unwrap_or_default().to_string_lossy().to_string();
201 let bytes = fs::read(path).await.expect("failed to read file");
202 let part = multipart::Part::bytes(bytes).file_name(filename);
203 form = form.part(field.to_string(), part);
204 }
205 let resp = req.multipart(form).send().await?.error_for_status()?;
206 if resp.status() == StatusCode::NO_CONTENT {
207 return Ok(None);
208 }
209 let data = resp.json::<Value>().await?;
210 Ok(Some(data))
211 }
212
213 pub async fn me(&self) -> Result<Option<Value>, reqwest::Error> {
217 self.request(
218 Method::GET,
219 "/tasks/bapp_framework.me",
220 None, None,
221 Some(&[("x-app-slug", "")]),
222 ).await
223 }
224
225 pub async fn get_app(&self, app_slug: &str) -> Result<Option<Value>, reqwest::Error> {
229 self.request(
230 Method::GET,
231 "/tasks/bapp_framework.getapp",
232 None, None,
233 Some(&[("x-app-slug", app_slug)]),
234 ).await
235 }
236
237 pub async fn list_introspect(&self, content_type: &str) -> Result<Option<Value>, reqwest::Error> {
241 self.request(
242 Method::GET,
243 "/tasks/bapp_framework.listintrospect",
244 Some(&[("ct", content_type)]),
245 None, None,
246 ).await
247 }
248
249 pub async fn detail_introspect(
251 &self, content_type: &str, pk: Option<&str>,
252 ) -> Result<Option<Value>, reqwest::Error> {
253 let mut params = vec![("ct", content_type)];
254 if let Some(pk) = pk {
255 params.push(("pk", pk));
256 }
257 self.request(
258 Method::GET,
259 "/tasks/bapp_framework.detailintrospect",
260 Some(¶ms), None, None,
261 ).await
262 }
263
264 pub async fn list(
268 &self, content_type: &str, filters: Option<&[(&str, &str)]>,
269 ) -> Result<PagedList, Box<dyn std::error::Error>> {
270 let path = format!("/content-type/{}/", content_type);
271 let req = self.build_request(Method::GET, &path, filters, None);
272 let resp = req.send().await?.error_for_status()?;
273 let paged: PagedList = resp.json().await?;
274 Ok(paged)
275 }
276
277 pub async fn get(
279 &self, content_type: &str, id: &str,
280 ) -> Result<Option<Value>, reqwest::Error> {
281 let path = format!("/content-type/{}/{}/", content_type, id);
282 self.request(Method::GET, &path, None, None, None).await
283 }
284
285 pub async fn create(
287 &self, content_type: &str, data: Option<&Value>,
288 ) -> Result<Option<Value>, reqwest::Error> {
289 let path = format!("/content-type/{}/", content_type);
290 self.request(Method::POST, &path, None, data, None).await
291 }
292
293 pub async fn update(
295 &self, content_type: &str, id: &str, data: Option<&Value>,
296 ) -> Result<Option<Value>, reqwest::Error> {
297 let path = format!("/content-type/{}/{}/", content_type, id);
298 self.request(Method::PUT, &path, None, data, None).await
299 }
300
301 pub async fn patch(
303 &self, content_type: &str, id: &str, data: Option<&Value>,
304 ) -> Result<Option<Value>, reqwest::Error> {
305 let path = format!("/content-type/{}/{}/", content_type, id);
306 self.request(Method::PATCH, &path, None, data, None).await
307 }
308
309 pub async fn delete(
311 &self, content_type: &str, id: &str,
312 ) -> Result<Option<Value>, reqwest::Error> {
313 let path = format!("/content-type/{}/{}/", content_type, id);
314 self.request(Method::DELETE, &path, None, None, None).await
315 }
316
317 pub fn get_document_views(record: &Value) -> Vec<Value> {
325 let mut views = Vec::new();
326
327 if let Some(public_views) = record.get("public_view").and_then(|v| v.as_array()) {
328 for entry in public_views {
329 views.push(serde_json::json!({
330 "label": entry.get("label").and_then(|v| v.as_str()).unwrap_or(""),
331 "token": entry.get("view_token").and_then(|v| v.as_str()).unwrap_or(""),
332 "type": "public_view",
333 "variations": entry.get("variations").cloned().unwrap_or(Value::Null),
334 "default_variation": entry.get("default_variation").cloned().unwrap_or(Value::Null),
335 }));
336 }
337 }
338
339 if let Some(view_tokens) = record.get("view_token").and_then(|v| v.as_array()) {
340 for entry in view_tokens {
341 views.push(serde_json::json!({
342 "label": entry.get("label").and_then(|v| v.as_str()).unwrap_or(""),
343 "token": entry.get("view_token").and_then(|v| v.as_str()).unwrap_or(""),
344 "type": "view_token",
345 "variations": null,
346 "default_variation": null,
347 }));
348 }
349 }
350
351 views
352 }
353
354 pub fn get_document_url(
363 &self,
364 record: &Value,
365 output: &str,
366 label: Option<&str>,
367 variation: Option<&str>,
368 download: bool,
369 ) -> Option<String> {
370 let views = Self::get_document_views(record);
371 if views.is_empty() {
372 return None;
373 }
374
375 let view = if let Some(label) = label {
376 views.iter()
377 .find(|v| v.get("label").and_then(|l| l.as_str()) == Some(label))
378 .unwrap_or(&views[0])
379 } else {
380 &views[0]
381 };
382
383 let token = view.get("token").and_then(|v| v.as_str()).unwrap_or("");
384 if token.is_empty() {
385 return None;
386 }
387
388 let view_type = view.get("type").and_then(|v| v.as_str()).unwrap_or("");
389
390 if view_type == "public_view" {
391 let base = format!("{}/render/{}", self.host, token);
392 let mut u = Url::parse(&base).ok()?;
393 u.query_pairs_mut().append_pair("output", output);
394 let effective_variation = variation
395 .map(|s| s.to_string())
396 .or_else(|| {
397 view.get("default_variation")
398 .and_then(|v| v.as_str())
399 .map(|s| s.to_string())
400 });
401 if let Some(v) = effective_variation {
402 u.query_pairs_mut().append_pair("variation", &v);
403 }
404 if download {
405 u.query_pairs_mut().append_pair("download", "true");
406 }
407 return Some(u.to_string());
408 }
409
410 let action = match (output, download) {
412 ("pdf", true) => "pdf.download",
413 ("pdf", false) => "pdf.view",
414 ("context", _) => "pdf.context",
415 _ => "pdf.preview",
416 };
417 let base = format!("{}/documents/{}", self.host, action);
418 let mut u = Url::parse(&base).ok()?;
419 u.query_pairs_mut().append_pair("token", token);
420 Some(u.to_string())
421 }
422
423 pub async fn get_document_content(
428 &self,
429 record: &Value,
430 output: &str,
431 label: Option<&str>,
432 variation: Option<&str>,
433 download: bool,
434 ) -> Result<Option<Vec<u8>>, Box<dyn std::error::Error>> {
435 let url = match self.get_document_url(record, output, label, variation, download) {
436 Some(u) => u,
437 None => return Ok(None),
438 };
439 let resp = self.client.get(&url).send().await?.error_for_status()?;
440 let bytes = resp.bytes().await?;
441 Ok(Some(bytes.to_vec()))
442 }
443
444 pub async fn download_document(
449 &self,
450 record: &Value,
451 dest: &str,
452 output: &str,
453 label: Option<&str>,
454 variation: Option<&str>,
455 download: bool,
456 ) -> Result<bool, Box<dyn std::error::Error>> {
457 let url = match self.get_document_url(record, output, label, variation, download) {
458 Some(u) => u,
459 None => return Ok(false),
460 };
461 let resp = self.client.get(&url).send().await?.error_for_status()?;
462 let mut file = tokio::fs::File::create(dest).await?;
463 let bytes = resp.bytes().await?;
464 file.write_all(&bytes).await?;
465 Ok(true)
466 }
467
468 pub async fn list_tasks(&self) -> Result<Option<Value>, reqwest::Error> {
472 self.request(Method::GET, "/tasks", None, None, None).await
473 }
474
475 pub async fn detail_task(&self, code: &str) -> Result<Option<Value>, reqwest::Error> {
477 let path = format!("/tasks/{}", code);
478 self.request(Method::OPTIONS, &path, None, None, None).await
479 }
480
481 pub async fn run_task(
483 &self, code: &str, payload: Option<&Value>,
484 ) -> Result<Option<Value>, reqwest::Error> {
485 let path = format!("/tasks/{}", code);
486 let method = if payload.is_some() { Method::POST } else { Method::GET };
487 self.request(method, &path, None, payload, None).await
488 }
489
490 pub async fn run_task_async(
493 &self,
494 code: &str,
495 payload: Option<&Value>,
496 poll_interval: Option<Duration>,
497 timeout: Option<Duration>,
498 ) -> Result<Value, Box<dyn std::error::Error>> {
499 let poll = poll_interval.unwrap_or(Duration::from_secs(1));
500 let tout = timeout.unwrap_or(Duration::from_secs(300));
501
502 let result = self.run_task(code, payload).await?;
503 let task_id = result
504 .as_ref()
505 .and_then(|v| v.get("id"))
506 .and_then(|v| v.as_str())
507 .map(|s| s.to_string());
508
509 let task_id = match task_id {
510 Some(id) => id,
511 None => return Ok(result.unwrap_or(Value::Null)),
512 };
513
514 let deadline = tokio::time::Instant::now() + tout;
515 loop {
516 sleep(poll).await;
517 if tokio::time::Instant::now() > deadline {
518 return Err(format!("Task {} ({}) did not finish within {:?}", code, task_id, tout).into());
519 }
520 let page = self.list("bapp_framework.taskdata", Some(&[("id", &task_id)])).await?;
521 if page.results.is_empty() {
522 continue;
523 }
524 let data = &page.results[0];
525 if data.get("failed").and_then(|v| v.as_bool()).unwrap_or(false) {
526 let msg = data.get("message").and_then(|v| v.as_str()).unwrap_or("");
527 return Err(format!("Task {} failed: {}", code, msg).into());
528 }
529 if data.get("finished").and_then(|v| v.as_bool()).unwrap_or(false) {
530 return Ok(data.clone());
531 }
532 }
533 }
534}
535
536impl Default for BappApiClient {
537 fn default() -> Self {
538 Self::new()
539 }
540}