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 user_agent: Option<String>,
26 client: Client,
27}
28
29impl BappApiClient {
30 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 pub fn with_host(mut self, host: &str) -> Self {
44 self.host = host.trim_end_matches('/').to_string();
45 self
46 }
47
48 pub fn with_bearer(mut self, token: &str) -> Self {
50 self.auth_header = Some(format!("Bearer {}", token));
51 self
52 }
53
54 pub fn with_token(mut self, token: &str) -> Self {
56 self.auth_header = Some(format!("Token {}", token));
57 self
58 }
59
60 pub fn with_tenant(mut self, tenant: &str) -> Self {
62 self.tenant = Some(tenant.to_string());
63 self
64 }
65
66 pub fn with_app(mut self, app: &str) -> Self {
68 self.app = app.to_string();
69 self
70 }
71
72 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 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 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 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 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 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(¶ms), None, None,
204 ).await
205 }
206
207 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 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 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 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 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 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 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 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 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 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 pub async fn list_tasks(&self) -> Result<Option<Value>, reqwest::Error> {
386 self.request(Method::GET, "/tasks", None, None, None).await
387 }
388
389 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 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 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}