1#![allow(clippy::missing_errors_doc)]
2
3use anyhow::{Context, Result};
4use reqwest::{Client, header};
5use crate::models::{CreateDashboard, CreateQuery, CreateWidget, Dashboard, DashboardsResponse, DashboardSummary, DataSource, DataSourceSchema, QueriesResponse, Query};
6
7pub struct RedashClient {
8 client: Client,
9 base_url: String,
10}
11
12impl RedashClient {
13 pub fn new(base_url: String, api_key: &str) -> Result<Self> {
14 let mut headers = header::HeaderMap::new();
15 headers.insert(
16 "Authorization",
17 header::HeaderValue::from_str(&format!("Key {api_key}"))
18 .context("Invalid API key format")?,
19 );
20
21 let client = Client::builder()
22 .default_headers(headers)
23 .build()
24 .context("Failed to build HTTP client")?;
25
26 Ok(Self {
27 client,
28 base_url,
29 })
30 }
31
32 pub async fn list_my_queries(&self, page: u32, page_size: u32) -> Result<QueriesResponse> {
33 let url = format!("{}/api/queries/my?page={page}&page_size={page_size}", self.base_url);
34 let response = self.client
35 .get(&url)
36 .send()
37 .await
38 .context("Failed to fetch my queries")?
39 .error_for_status()
40 .context("API returned error status")?;
41
42 response
43 .json()
44 .await
45 .context("Failed to parse queries response")
46 }
47
48 pub async fn get_query(&self, query_id: u64) -> Result<Query> {
49 let url = format!("{}/api/queries/{query_id}", self.base_url);
50 let response = self.client
51 .get(&url)
52 .send()
53 .await
54 .context(format!("Failed to fetch query {query_id}"))?
55 .error_for_status()
56 .context("API returned error status")?;
57
58 response
59 .json()
60 .await
61 .context("Failed to parse query response")
62 }
63
64 pub async fn list_data_sources(&self) -> Result<Vec<DataSource>> {
65 let url = format!("{}/api/data_sources", self.base_url);
66 let response = self.client
67 .get(&url)
68 .send()
69 .await
70 .context("Failed to fetch data sources")?
71 .error_for_status()
72 .context("API returned error status")?;
73
74 response
75 .json()
76 .await
77 .context("Failed to parse data sources response")
78 }
79
80 pub async fn get_data_source(&self, data_source_id: u64) -> Result<DataSource> {
81 let url = format!("{}/api/data_sources/{data_source_id}", self.base_url);
82 let response = self.client
83 .get(&url)
84 .send()
85 .await
86 .context(format!("Failed to fetch data source {data_source_id}"))?
87 .error_for_status()
88 .context("API returned error status")?;
89
90 response
91 .json()
92 .await
93 .context("Failed to parse data source response")
94 }
95
96 pub async fn get_data_source_schema(
97 &self,
98 data_source_id: u64,
99 refresh: bool,
100 ) -> Result<DataSourceSchema> {
101 let url = if refresh {
102 format!("{}/api/data_sources/{data_source_id}/schema?refresh=true", self.base_url)
103 } else {
104 format!("{}/api/data_sources/{data_source_id}/schema", self.base_url)
105 };
106
107 let response = self.client
108 .get(&url)
109 .send()
110 .await
111 .context(format!("Failed to fetch schema for data source {data_source_id}"))?
112 .error_for_status()
113 .context("API returned error status")?;
114
115 response
116 .json()
117 .await
118 .context("Failed to parse schema response")
119 }
120
121 pub async fn create_query(&self, create_query: &CreateQuery) -> Result<Query> {
122 let url = format!("{}/api/queries", self.base_url);
123 let response = self.client
124 .post(&url)
125 .json(create_query)
126 .send()
127 .await
128 .context("Failed to create query")?
129 .error_for_status()
130 .context("API returned error status")?;
131
132 response
133 .json()
134 .await
135 .context("Failed to parse query create response")
136 }
137
138 pub async fn create_or_update_query(&self, query: &Query) -> Result<Query> {
139 let url = format!("{}/api/queries/{}", self.base_url, query.id);
140 let response = self.client
141 .post(&url)
142 .json(query)
143 .send()
144 .await
145 .context(format!("Failed to update query {}", query.id))?
146 .error_for_status()
147 .context("API returned error status")?;
148
149 response
150 .json()
151 .await
152 .context("Failed to parse query update response")
153 }
154
155 pub async fn create_visualization(&self, query_id: u64, viz: &crate::models::CreateVisualization) -> Result<crate::models::Visualization> {
156 let url = format!("{}/api/visualizations", self.base_url);
157 let response = self.client
158 .post(&url)
159 .json(viz)
160 .send()
161 .await
162 .context(format!("Failed to create visualization for query {query_id}"))?
163 .error_for_status()
164 .context("API returned error status")?;
165
166 response
167 .json()
168 .await
169 .context("Failed to parse visualization create response")
170 }
171
172 pub async fn update_visualization(&self, viz: &crate::models::Visualization) -> Result<crate::models::Visualization> {
173 let url = format!("{}/api/visualizations/{}", self.base_url, viz.id);
174 let response = self.client
175 .post(&url)
176 .json(viz)
177 .send()
178 .await
179 .context(format!("Failed to update visualization {}", viz.id))?
180 .error_for_status()
181 .context("API returned error status")?;
182
183 response
184 .json()
185 .await
186 .context("Failed to parse visualization update response")
187 }
188
189 pub async fn fetch_all_queries(&self) -> Result<Vec<Query>> {
190 let mut all_queries = Vec::new();
191 let mut page = 1;
192 let page_size = 100;
193
194 loop {
195 let response = self.list_my_queries(page, page_size).await?;
196
197 if response.results.is_empty() {
198 break;
199 }
200
201 all_queries.extend(response.results);
202 eprintln!("Fetched {} / {} queries...", all_queries.len(), response.count);
203
204 #[allow(clippy::cast_possible_truncation)]
205 if all_queries.len() >= response.count as usize {
206 break;
207 }
208
209 page += 1;
210 }
211
212 Ok(all_queries)
213 }
214
215 pub async fn refresh_query(
216 &self,
217 query_id: u64,
218 parameters: Option<std::collections::HashMap<String, serde_json::Value>>,
219 ) -> Result<crate::models::Job> {
220 let url = format!("{}/api/queries/{query_id}/results", self.base_url);
221
222 let request = crate::models::RefreshRequest {
223 max_age: 0,
224 parameters,
225 };
226
227 let response = self.client
228 .post(&url)
229 .json(&request)
230 .send()
231 .await
232 .context(format!("Failed to refresh query {query_id}"))?;
233
234 let status = response.status();
235 if !status.is_success() {
236 let error_body = response.text().await.unwrap_or_else(|_| "Unable to read error response".to_string());
237 anyhow::bail!("API returned error status {status}: {error_body}");
238 }
239
240 let job_response: crate::models::JobResponse = response
241 .json()
242 .await
243 .context("Failed to parse job response")?;
244
245 Ok(job_response.job)
246 }
247
248 pub async fn poll_job(&self, job_id: &str) -> Result<crate::models::Job> {
249 let url = format!("{}/api/jobs/{job_id}", self.base_url);
250
251 let response = self.client
252 .get(&url)
253 .send()
254 .await
255 .context(format!("Failed to poll job {job_id}"))?
256 .error_for_status()
257 .context("API returned error status")?;
258
259 let job_response: crate::models::JobResponse = response
260 .json()
261 .await
262 .context("Failed to parse job response")?;
263
264 Ok(job_response.job)
265 }
266
267 pub async fn get_query_result(
268 &self,
269 query_id: u64,
270 result_id: u64,
271 ) -> Result<crate::models::QueryResult> {
272 let url = format!(
273 "{}/api/queries/{query_id}/results/{result_id}.json",
274 self.base_url
275 );
276
277 let response = self.client
278 .get(&url)
279 .send()
280 .await
281 .context(format!("Failed to fetch result {result_id} for query {query_id}"))?
282 .error_for_status()
283 .context("API returned error status")?;
284
285 let result_response: crate::models::QueryResultResponse = response
286 .json()
287 .await
288 .context("Failed to parse query result response")?;
289
290 Ok(result_response.query_result)
291 }
292
293 pub async fn execute_query_with_polling(
294 &self,
295 query_id: u64,
296 parameters: Option<std::collections::HashMap<String, serde_json::Value>>,
297 timeout_secs: u64,
298 poll_interval_ms: u64,
299 ) -> Result<crate::models::QueryResult> {
300 use tokio::time::{sleep, Duration};
301 use crate::models::JobStatus;
302
303 eprintln!("Executing query {query_id}...");
304 let job = self.refresh_query(query_id, parameters).await?;
305
306 let start = std::time::Instant::now();
307 let timeout = Duration::from_secs(timeout_secs);
308 let poll_interval = Duration::from_millis(poll_interval_ms);
309
310 let mut current_job = job;
311 loop {
312 if start.elapsed() > timeout {
313 anyhow::bail!("Query execution timed out after {timeout_secs} seconds");
314 }
315
316 let status = JobStatus::from_u8(current_job.status)?;
317
318 match status {
319 JobStatus::Success => {
320 let result_id = current_job.query_result_id
321 .context("Job succeeded but no result_id returned")?;
322
323 eprintln!("Query completed, fetching results...");
324 return self.get_query_result(query_id, result_id).await;
325 }
326 JobStatus::Failure => {
327 let error = current_job.error.unwrap_or_else(|| "Unknown error".to_string());
328 anyhow::bail!("Query execution failed: {error}");
329 }
330 JobStatus::Cancelled => {
331 anyhow::bail!("Query execution was cancelled");
332 }
333 JobStatus::Pending | JobStatus::Started => {
334 eprint!(".");
335 sleep(poll_interval).await;
336 current_job = self.poll_job(¤t_job.id).await?;
337 }
338 }
339 }
340 }
341
342 pub async fn archive_query(&self, query_id: u64) -> Result<Query> {
343 let url = format!("{}/api/queries/{query_id}", self.base_url);
344 let payload = serde_json::json!({"is_archived": true});
345
346 let response = self.client
347 .post(&url)
348 .json(&payload)
349 .send()
350 .await
351 .context(format!("Failed to archive query {query_id}"))?
352 .error_for_status()
353 .context("API returned error status")?;
354
355 response
356 .json()
357 .await
358 .context("Failed to parse archive response")
359 }
360
361 pub async fn unarchive_query(&self, query_id: u64) -> Result<Query> {
362 let url = format!("{}/api/queries/{query_id}", self.base_url);
363 let payload = serde_json::json!({"is_archived": false});
364
365 let response = self.client
366 .post(&url)
367 .json(&payload)
368 .send()
369 .await
370 .context(format!("Failed to unarchive query {query_id}"))?
371 .error_for_status()
372 .context("API returned error status")?;
373
374 response
375 .json()
376 .await
377 .context("Failed to parse unarchive response")
378 }
379
380 pub async fn create_dashboard(&self, dashboard: &CreateDashboard) -> Result<Dashboard> {
381 let url = format!("{}/api/dashboards", self.base_url);
382 let response = self.client
383 .post(&url)
384 .json(dashboard)
385 .send()
386 .await
387 .context("Failed to create dashboard")?;
388
389 let status = response.status();
390 if !status.is_success() {
391 anyhow::bail!("HTTP {}: {}", status.as_u16(), status.canonical_reason().unwrap_or("Unknown error"));
392 }
393
394 response
395 .json()
396 .await
397 .context("Failed to parse dashboard create response")
398 }
399
400 pub async fn list_favorite_dashboards(&self, page: u32, page_size: u32) -> Result<DashboardsResponse> {
401 let url = format!("{}/api/dashboards/favorites?page={page}&page_size={page_size}", self.base_url);
402 let response = self.client
403 .get(&url)
404 .send()
405 .await
406 .context("Failed to fetch dashboards")?;
407
408 let status = response.status();
409 if !status.is_success() {
410 anyhow::bail!("HTTP {}: {}", status.as_u16(), status.canonical_reason().unwrap_or("Unknown error"));
411 }
412
413 response
414 .json()
415 .await
416 .context("Failed to parse dashboards response")
417 }
418
419 pub async fn get_dashboard(&self, slug_or_id: &str) -> Result<Dashboard> {
420 let url = format!("{}/api/dashboards/{slug_or_id}", self.base_url);
421 let response = self.client
422 .get(&url)
423 .send()
424 .await
425 .context(format!("Failed to fetch dashboard {slug_or_id}"))?;
426
427 let status = response.status();
428 if !status.is_success() {
429 let body = response.text().await.unwrap_or_default();
430 anyhow::bail!("HTTP {}: {} — {body}", status.as_u16(), status.canonical_reason().unwrap_or("Unknown error"));
431 }
432
433 response
434 .json()
435 .await
436 .context("Failed to parse dashboard response")
437 }
438
439 pub async fn update_dashboard(&self, dashboard: &Dashboard) -> Result<Dashboard> {
440 let url = format!("{}/api/dashboards/{}", self.base_url, dashboard.id);
441 let response = self.client
442 .post(&url)
443 .json(dashboard)
444 .send()
445 .await
446 .context(format!("Failed to update dashboard {}", dashboard.id))?;
447
448 let status = response.status();
449 if !status.is_success() {
450 let body = response.text().await.unwrap_or_default();
451 anyhow::bail!("HTTP {}: {} — {body}", status.as_u16(), status.canonical_reason().unwrap_or("Unknown error"));
452 }
453
454 response
455 .json()
456 .await
457 .context("Failed to parse dashboard update response")
458 }
459
460 pub async fn archive_dashboard(&self, slug: &str) -> Result<()> {
461 let url = format!("{}/api/dashboards/{slug}", self.base_url);
462 let response = self.client
463 .delete(&url)
464 .send()
465 .await
466 .context(format!("Failed to archive dashboard {slug}"))?;
467
468 let status = response.status();
469 if !status.is_success() {
470 anyhow::bail!("HTTP {}: {}", status.as_u16(), status.canonical_reason().unwrap_or("Unknown error"));
471 }
472
473 Ok(())
474 }
475
476 pub async fn unarchive_dashboard(&self, dashboard_id: u64) -> Result<Dashboard> {
477 let url = format!("{}/api/dashboards/{dashboard_id}", self.base_url);
478 let payload = serde_json::json!({"is_archived": false});
479
480 let response = self.client
481 .post(&url)
482 .json(&payload)
483 .send()
484 .await
485 .context(format!("Failed to unarchive dashboard {dashboard_id}"))?;
486
487 let status = response.status();
488 if !status.is_success() {
489 anyhow::bail!("HTTP {}: {}", status.as_u16(), status.canonical_reason().unwrap_or("Unknown error"));
490 }
491
492 response
493 .json()
494 .await
495 .context("Failed to parse unarchive response")
496 }
497
498 pub async fn create_widget(&self, widget: &CreateWidget) -> Result<crate::models::Widget> {
499 let url = format!("{}/api/widgets", self.base_url);
500 let response = self.client
501 .post(&url)
502 .json(widget)
503 .send()
504 .await
505 .context("Failed to create widget")?;
506
507 let status = response.status();
508 if !status.is_success() {
509 let body = response.text().await.unwrap_or_default();
510 anyhow::bail!("HTTP {}: {} — {body}", status.as_u16(), status.canonical_reason().unwrap_or("Unknown error"));
511 }
512
513 response
514 .json()
515 .await
516 .context("Failed to parse widget create response")
517 }
518
519 pub async fn delete_widget(&self, widget_id: u64) -> Result<()> {
520 let url = format!("{}/api/widgets/{widget_id}", self.base_url);
521 let response = self.client
522 .delete(&url)
523 .send()
524 .await
525 .context(format!("Failed to delete widget {widget_id}"))?;
526
527 let status = response.status();
528 if !status.is_success() {
529 anyhow::bail!("HTTP {}: {}", status.as_u16(), status.canonical_reason().unwrap_or("Unknown error"));
530 }
531
532 Ok(())
533 }
534
535 pub async fn favorite_dashboard(&self, slug: &str) -> Result<()> {
536 let url = format!("{}/api/dashboards/{slug}/favorite", self.base_url);
537 let response = self.client
538 .post(&url)
539 .json(&serde_json::json!({}))
540 .send()
541 .await
542 .context(format!("Failed to favorite dashboard {slug}"))?;
543
544 let status = response.status();
545 if !status.is_success() {
546 anyhow::bail!("HTTP {}: {}", status.as_u16(), status.canonical_reason().unwrap_or("Unknown error"));
547 }
548
549 Ok(())
550 }
551
552 pub async fn fetch_favorite_dashboards(&self) -> Result<Vec<DashboardSummary>> {
553 let mut all_dashboards = Vec::new();
554 let mut page = 1;
555 let page_size = 100;
556
557 loop {
558 let response = self.list_favorite_dashboards(page, page_size).await?;
559
560 if response.results.is_empty() {
561 break;
562 }
563
564 all_dashboards.extend(response.results);
565 eprintln!("Fetched {} / {} dashboards...", all_dashboards.len(), response.count);
566
567 #[allow(clippy::cast_possible_truncation)]
568 if all_dashboards.len() >= response.count as usize {
569 break;
570 }
571
572 page += 1;
573 }
574
575 Ok(all_dashboards)
576 }
577}