Skip to main content

bapp_api_client/
lib.rs

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/// Paginated list response.
13#[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/// BAPP Auto API client.
22#[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    /// Create a new client with the default host.
35    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    /// Create a new client pointing at `host`.
51    pub fn with_host(mut self, host: &str) -> Self {
52        self.host = host.trim_end_matches('/').to_string();
53        self
54    }
55
56    /// Set Bearer token authentication.
57    pub fn with_bearer(mut self, token: &str) -> Self {
58        self.auth_header = Some(format!("Bearer {}", token));
59        self
60    }
61
62    /// Set Token-based authentication.
63    pub fn with_token(mut self, token: &str) -> Self {
64        self.auth_header = Some(format!("Token {}", token));
65        self
66    }
67
68    /// Set the default tenant ID.
69    pub fn with_tenant(mut self, tenant: &str) -> Self {
70        self.tenant = Some(tenant.to_string());
71        self
72    }
73
74    /// Set the default app slug.
75    pub fn with_app(mut self, app: &str) -> Self {
76        self.app = app.to_string();
77        self
78    }
79
80    /// Set a custom User-Agent header.
81    pub fn with_user_agent(mut self, ua: &str) -> Self {
82        self.user_agent = Some(ua.to_string());
83        self
84    }
85
86    /// Set the HTTP client timeout.
87    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    /// Set the maximum number of retries on transient errors.
96    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    /// Send a multipart/form-data request. Use for file uploads.
185    /// `fields` are plain text fields, `files` are `(field_name, file_path)` pairs.
186    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    // -- user ---------------------------------------------------------------
214
215    /// Get current user profile.
216    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    // -- app ----------------------------------------------------------------
226
227    /// Get app configuration by slug.
228    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    // -- entity introspect --------------------------------------------------
238
239    /// Get entity list introspect for a content type.
240    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    /// Get entity detail introspect for a content type.
250    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(&params), None, None,
261        ).await
262    }
263
264    // -- entity CRUD --------------------------------------------------------
265
266    /// List entities of a content type. Returns a [PagedList].
267    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    /// Get a single entity by content type and ID.
278    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    /// Create a new entity.
286    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    /// Full update of an entity.
294    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    /// Partial update of an entity.
302    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    /// Delete an entity.
310    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    // -- document views -----------------------------------------------------
318
319    /// Extract available document views from a record.
320    ///
321    /// Works with both `public_view` (new) and `view_token` (legacy) formats.
322    /// Returns a Vec of JSON objects with keys: `label`, `token`, `type`,
323    /// `variations`, and `default_variation`.
324    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    /// Build a document render/download URL from a record.
355    ///
356    /// Works with both `public_view` and `view_token` formats.
357    /// Prefers `public_view` when both are present on a record.
358    ///
359    /// - `output`: `"html"`, `"pdf"`, `"jpg"`, or `"context"`.
360    /// - `label`: select a specific view by label (`None` = first available).
361    /// - `variation`: variation code for `public_view` entries (e.g. `"v4"`).
362    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        // Legacy view_token
411        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    /// Fetch document content (PDF, HTML, JPG, etc.) as bytes.
424    ///
425    /// Builds the URL via [`get_document_url`] and performs a plain GET request.
426    /// Returns `Ok(None)` when the record has no view tokens.
427    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    /// Stream document content directly to a file.
445    ///
446    /// Like [`get_document_content`] but streams to `dest` without buffering
447    /// the entire document in memory.
448    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    // -- tasks --------------------------------------------------------------
469
470    /// List all available task codes.
471    pub async fn list_tasks(&self) -> Result<Option<Value>, reqwest::Error> {
472        self.request(Method::GET, "/tasks", None, None, None).await
473    }
474
475    /// Get task configuration by code.
476    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    /// Run a task. Uses GET when no payload, POST otherwise.
482    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    /// Run a long-running task and poll until finished.
491    /// Returns the final task data which includes "file" when the task produces a download.
492    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}