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