Skip to main content

bapp_api_client/
lib.rs

1use reqwest::{multipart, Client, Method, StatusCode};
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::path::Path;
5use std::time::Duration;
6use tokio::fs;
7use tokio::time::sleep;
8
9/// Paginated list response.
10#[derive(Debug, Deserialize, Serialize)]
11pub struct PagedList {
12    pub results: Vec<Value>,
13    pub count: i64,
14    pub next: Option<String>,
15    pub previous: Option<String>,
16}
17
18/// BAPP Auto API client.
19#[derive(Debug)]
20pub struct BappApiClient {
21    pub host: String,
22    pub tenant: Option<String>,
23    pub app: String,
24    auth_header: Option<String>,
25    user_agent: Option<String>,
26    client: Client,
27}
28
29impl BappApiClient {
30    /// Create a new client with the default host.
31    pub fn new() -> Self {
32        Self {
33            host: "https://panel.bapp.ro/api".to_string(),
34            tenant: None,
35            app: "account".to_string(),
36            auth_header: None,
37            user_agent: None,
38            client: Client::new(),
39        }
40    }
41
42    /// Create a new client pointing at `host`.
43    pub fn with_host(mut self, host: &str) -> Self {
44        self.host = host.trim_end_matches('/').to_string();
45        self
46    }
47
48    /// Set Bearer token authentication.
49    pub fn with_bearer(mut self, token: &str) -> Self {
50        self.auth_header = Some(format!("Bearer {}", token));
51        self
52    }
53
54    /// Set Token-based authentication.
55    pub fn with_token(mut self, token: &str) -> Self {
56        self.auth_header = Some(format!("Token {}", token));
57        self
58    }
59
60    /// Set the default tenant ID.
61    pub fn with_tenant(mut self, tenant: &str) -> Self {
62        self.tenant = Some(tenant.to_string());
63        self
64    }
65
66    /// Set the default app slug.
67    pub fn with_app(mut self, app: &str) -> Self {
68        self.app = app.to_string();
69        self
70    }
71
72    /// Set a custom User-Agent header.
73    pub fn with_user_agent(mut self, ua: &str) -> Self {
74        self.user_agent = Some(ua.to_string());
75        self
76    }
77
78    fn build_request(
79        &self,
80        method: Method,
81        path: &str,
82        params: Option<&[(&str, &str)]>,
83        extra_headers: Option<&[(&str, &str)]>,
84    ) -> reqwest::RequestBuilder {
85        let url = format!("{}{}", self.host, path);
86        let mut req = self.client.request(method, &url);
87        if let Some(p) = params {
88            req = req.query(p);
89        }
90        if let Some(ua) = &self.user_agent {
91            req = req.header("User-Agent", ua);
92        }
93        if let Some(auth) = &self.auth_header {
94            req = req.header("Authorization", auth);
95        }
96        if let Some(t) = &self.tenant {
97            req = req.header("x-tenant-id", t);
98        }
99        req = req.header("x-app-slug", &self.app);
100        if let Some(extra) = extra_headers {
101            for (k, v) in extra {
102                req = req.header(*k, *v);
103            }
104        }
105        req
106    }
107
108    async fn send(req: reqwest::RequestBuilder) -> Result<Option<Value>, reqwest::Error> {
109        let resp = req.send().await?.error_for_status()?;
110        if resp.status() == StatusCode::NO_CONTENT {
111            return Ok(None);
112        }
113        let data = resp.json::<Value>().await?;
114        Ok(Some(data))
115    }
116
117    async fn request(
118        &self,
119        method: Method,
120        path: &str,
121        params: Option<&[(&str, &str)]>,
122        body: Option<&Value>,
123        extra_headers: Option<&[(&str, &str)]>,
124    ) -> Result<Option<Value>, reqwest::Error> {
125        let mut req = self.build_request(method, path, params, extra_headers);
126        if let Some(b) = body {
127            req = req.json(b);
128        }
129        Self::send(req).await
130    }
131
132    /// Send a multipart/form-data request. Use for file uploads.
133    /// `fields` are plain text fields, `files` are `(field_name, file_path)` pairs.
134    pub async fn request_multipart(
135        &self,
136        method: Method,
137        path: &str,
138        fields: &[(&str, &str)],
139        files: &[(&str, &str)],
140    ) -> Result<Option<Value>, reqwest::Error> {
141        let req = self.build_request(method, path, None, None);
142        let mut form = multipart::Form::new();
143        for (k, v) in fields {
144            form = form.text(k.to_string(), v.to_string());
145        }
146        for (field, file_path) in files {
147            let path = Path::new(file_path);
148            let filename = path.file_name().unwrap_or_default().to_string_lossy().to_string();
149            let bytes = fs::read(path).await.expect("failed to read file");
150            let part = multipart::Part::bytes(bytes).file_name(filename);
151            form = form.part(field.to_string(), part);
152        }
153        Self::send(req.multipart(form)).await
154    }
155
156    // -- user ---------------------------------------------------------------
157
158    /// Get current user profile.
159    pub async fn me(&self) -> Result<Option<Value>, reqwest::Error> {
160        self.request(
161            Method::GET,
162            "/tasks/bapp_framework.me",
163            None, None,
164            Some(&[("x-app-slug", "")]),
165        ).await
166    }
167
168    // -- app ----------------------------------------------------------------
169
170    /// Get app configuration by slug.
171    pub async fn get_app(&self, app_slug: &str) -> Result<Option<Value>, reqwest::Error> {
172        self.request(
173            Method::GET,
174            "/tasks/bapp_framework.getapp",
175            None, None,
176            Some(&[("x-app-slug", app_slug)]),
177        ).await
178    }
179
180    // -- entity introspect --------------------------------------------------
181
182    /// Get entity list introspect for a content type.
183    pub async fn list_introspect(&self, content_type: &str) -> Result<Option<Value>, reqwest::Error> {
184        self.request(
185            Method::GET,
186            "/tasks/bapp_framework.listintrospect",
187            Some(&[("ct", content_type)]),
188            None, None,
189        ).await
190    }
191
192    /// Get entity detail introspect for a content type.
193    pub async fn detail_introspect(
194        &self, content_type: &str, pk: Option<&str>,
195    ) -> Result<Option<Value>, reqwest::Error> {
196        let mut params = vec![("ct", content_type)];
197        if let Some(pk) = pk {
198            params.push(("pk", pk));
199        }
200        self.request(
201            Method::GET,
202            "/tasks/bapp_framework.detailintrospect",
203            Some(&params), None, None,
204        ).await
205    }
206
207    // -- entity CRUD --------------------------------------------------------
208
209    /// List entities of a content type. Returns a [PagedList].
210    pub async fn list(
211        &self, content_type: &str, filters: Option<&[(&str, &str)]>,
212    ) -> Result<PagedList, Box<dyn std::error::Error>> {
213        let path = format!("/content-type/{}/", content_type);
214        let req = self.build_request(Method::GET, &path, filters, None);
215        let resp = req.send().await?.error_for_status()?;
216        let paged: PagedList = resp.json().await?;
217        Ok(paged)
218    }
219
220    /// Get a single entity by content type and ID.
221    pub async fn get(
222        &self, content_type: &str, id: &str,
223    ) -> Result<Option<Value>, reqwest::Error> {
224        let path = format!("/content-type/{}/{}/", content_type, id);
225        self.request(Method::GET, &path, None, None, None).await
226    }
227
228    /// Create a new entity.
229    pub async fn create(
230        &self, content_type: &str, data: Option<&Value>,
231    ) -> Result<Option<Value>, reqwest::Error> {
232        let path = format!("/content-type/{}/", content_type);
233        self.request(Method::POST, &path, None, data, None).await
234    }
235
236    /// Full update of an entity.
237    pub async fn update(
238        &self, content_type: &str, id: &str, data: Option<&Value>,
239    ) -> Result<Option<Value>, reqwest::Error> {
240        let path = format!("/content-type/{}/{}/", content_type, id);
241        self.request(Method::PUT, &path, None, data, None).await
242    }
243
244    /// Partial update of an entity.
245    pub async fn patch(
246        &self, content_type: &str, id: &str, data: Option<&Value>,
247    ) -> Result<Option<Value>, reqwest::Error> {
248        let path = format!("/content-type/{}/{}/", content_type, id);
249        self.request(Method::PATCH, &path, None, data, None).await
250    }
251
252    /// Delete an entity.
253    pub async fn delete(
254        &self, content_type: &str, id: &str,
255    ) -> Result<Option<Value>, reqwest::Error> {
256        let path = format!("/content-type/{}/{}/", content_type, id);
257        self.request(Method::DELETE, &path, None, None, None).await
258    }
259
260    // -- document views -----------------------------------------------------
261
262    /// Extract available document views from a record.
263    ///
264    /// Works with both `public_view` (new) and `view_token` (legacy) formats.
265    /// Returns a Vec of JSON objects with keys: `label`, `token`, `type`,
266    /// `variations`, and `default_variation`.
267    pub fn get_document_views(record: &Value) -> Vec<Value> {
268        let mut views = Vec::new();
269
270        if let Some(public_views) = record.get("public_view").and_then(|v| v.as_array()) {
271            for entry in public_views {
272                views.push(serde_json::json!({
273                    "label": entry.get("label").and_then(|v| v.as_str()).unwrap_or(""),
274                    "token": entry.get("view_token").and_then(|v| v.as_str()).unwrap_or(""),
275                    "type": "public_view",
276                    "variations": entry.get("variations").cloned().unwrap_or(Value::Null),
277                    "default_variation": entry.get("default_variation").cloned().unwrap_or(Value::Null),
278                }));
279            }
280        }
281
282        if let Some(view_tokens) = record.get("view_token").and_then(|v| v.as_array()) {
283            for entry in view_tokens {
284                views.push(serde_json::json!({
285                    "label": entry.get("label").and_then(|v| v.as_str()).unwrap_or(""),
286                    "token": entry.get("view_token").and_then(|v| v.as_str()).unwrap_or(""),
287                    "type": "view_token",
288                    "variations": null,
289                    "default_variation": null,
290                }));
291            }
292        }
293
294        views
295    }
296
297    /// Build a document render/download URL from a record.
298    ///
299    /// Works with both `public_view` and `view_token` formats.
300    /// Prefers `public_view` when both are present on a record.
301    ///
302    /// - `output`: `"html"`, `"pdf"`, `"jpg"`, or `"context"`.
303    /// - `label`: select a specific view by label (`None` = first available).
304    /// - `variation`: variation code for `public_view` entries (e.g. `"v4"`).
305    pub fn get_document_url(
306        &self,
307        record: &Value,
308        output: &str,
309        label: Option<&str>,
310        variation: Option<&str>,
311        download: bool,
312    ) -> Option<String> {
313        let views = Self::get_document_views(record);
314        if views.is_empty() {
315            return None;
316        }
317
318        let view = if let Some(label) = label {
319            views.iter()
320                .find(|v| v.get("label").and_then(|l| l.as_str()) == Some(label))
321                .unwrap_or(&views[0])
322        } else {
323            &views[0]
324        };
325
326        let token = view.get("token").and_then(|v| v.as_str()).unwrap_or("");
327        if token.is_empty() {
328            return None;
329        }
330
331        let view_type = view.get("type").and_then(|v| v.as_str()).unwrap_or("");
332
333        if view_type == "public_view" {
334            let mut url = format!("{}/render/{}?output={}", self.host, token, output);
335            let effective_variation = variation
336                .map(|s| s.to_string())
337                .or_else(|| {
338                    view.get("default_variation")
339                        .and_then(|v| v.as_str())
340                        .map(|s| s.to_string())
341                });
342            if let Some(v) = effective_variation {
343                url.push_str(&format!("&variation={}", v));
344            }
345            if download {
346                url.push_str("&download=true");
347            }
348            return Some(url);
349        }
350
351        // Legacy view_token
352        let action = match (output, download) {
353            ("pdf", true) => "pdf.download",
354            ("pdf", false) => "pdf.view",
355            ("context", _) => "pdf.context",
356            _ => "pdf.preview",
357        };
358        Some(format!("{}/documents/{}?token={}", self.host, action, token))
359    }
360
361    /// Fetch document content (PDF, HTML, JPG, etc.) as bytes.
362    ///
363    /// Builds the URL via [`get_document_url`] and performs a plain GET request.
364    /// Returns `Ok(None)` when the record has no view tokens.
365    pub async fn get_document_content(
366        &self,
367        record: &Value,
368        output: &str,
369        label: Option<&str>,
370        variation: Option<&str>,
371        download: bool,
372    ) -> Result<Option<Vec<u8>>, Box<dyn std::error::Error>> {
373        let url = match self.get_document_url(record, output, label, variation, download) {
374            Some(u) => u,
375            None => return Ok(None),
376        };
377        let resp = self.client.get(&url).send().await?.error_for_status()?;
378        let bytes = resp.bytes().await?;
379        Ok(Some(bytes.to_vec()))
380    }
381
382    // -- tasks --------------------------------------------------------------
383
384    /// List all available task codes.
385    pub async fn list_tasks(&self) -> Result<Option<Value>, reqwest::Error> {
386        self.request(Method::GET, "/tasks", None, None, None).await
387    }
388
389    /// Get task configuration by code.
390    pub async fn detail_task(&self, code: &str) -> Result<Option<Value>, reqwest::Error> {
391        let path = format!("/tasks/{}", code);
392        self.request(Method::OPTIONS, &path, None, None, None).await
393    }
394
395    /// Run a task. Uses GET when no payload, POST otherwise.
396    pub async fn run_task(
397        &self, code: &str, payload: Option<&Value>,
398    ) -> Result<Option<Value>, reqwest::Error> {
399        let path = format!("/tasks/{}", code);
400        let method = if payload.is_some() { Method::POST } else { Method::GET };
401        self.request(method, &path, None, payload, None).await
402    }
403
404    /// Run a long-running task and poll until finished.
405    /// Returns the final task data which includes "file" when the task produces a download.
406    pub async fn run_task_async(
407        &self,
408        code: &str,
409        payload: Option<&Value>,
410        poll_interval: Option<Duration>,
411        timeout: Option<Duration>,
412    ) -> Result<Value, Box<dyn std::error::Error>> {
413        let poll = poll_interval.unwrap_or(Duration::from_secs(1));
414        let tout = timeout.unwrap_or(Duration::from_secs(300));
415
416        let result = self.run_task(code, payload).await?;
417        let task_id = result
418            .as_ref()
419            .and_then(|v| v.get("id"))
420            .and_then(|v| v.as_str())
421            .map(|s| s.to_string());
422
423        let task_id = match task_id {
424            Some(id) => id,
425            None => return Ok(result.unwrap_or(Value::Null)),
426        };
427
428        let deadline = tokio::time::Instant::now() + tout;
429        loop {
430            sleep(poll).await;
431            if tokio::time::Instant::now() > deadline {
432                return Err(format!("Task {} ({}) did not finish within {:?}", code, task_id, tout).into());
433            }
434            let page = self.list("bapp_framework.taskdata", Some(&[("id", &task_id)])).await?;
435            if page.results.is_empty() {
436                continue;
437            }
438            let data = &page.results[0];
439            if data.get("failed").and_then(|v| v.as_bool()).unwrap_or(false) {
440                let msg = data.get("message").and_then(|v| v.as_str()).unwrap_or("");
441                return Err(format!("Task {} failed: {}", code, msg).into());
442            }
443            if data.get("finished").and_then(|v| v.as_bool()).unwrap_or(false) {
444                return Ok(data.clone());
445            }
446        }
447    }
448}
449
450impl Default for BappApiClient {
451    fn default() -> Self {
452        Self::new()
453    }
454}