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#[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#[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 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 pub fn with_host(mut self, host: &str) -> Self {
42 self.host = host.trim_end_matches('/').to_string();
43 self
44 }
45
46 pub fn with_bearer(mut self, token: &str) -> Self {
48 self.auth_header = Some(format!("Bearer {}", token));
49 self
50 }
51
52 pub fn with_token(mut self, token: &str) -> Self {
54 self.auth_header = Some(format!("Token {}", token));
55 self
56 }
57
58 pub fn with_tenant(mut self, tenant: &str) -> Self {
60 self.tenant = Some(tenant.to_string());
61 self
62 }
63
64 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 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 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 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 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 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(¶ms), None, None,
193 ).await
194 }
195
196 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 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 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 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 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 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 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 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 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 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 pub async fn list_tasks(&self) -> Result<Option<Value>, reqwest::Error> {
369 self.request(Method::GET, "/tasks", None, None, None).await
370 }
371
372 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 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 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}