1#![allow(clippy::missing_errors_doc)]
2
3use crate::models::{
4 CreateDashboard, CreateQuery, CreateQuerySnippet, CreateWidget, Dashboard, DashboardSummary,
5 DashboardsResponse, DataSource, DataSourceSchema, QueriesResponse, Query, QuerySnippet,
6};
7use anyhow::{Context, Result};
8use reqwest::{Client, header};
9use serde::Serialize;
10use serde::de::DeserializeOwned;
11
12#[derive(Clone)]
15pub struct RedashClient {
16 client: Client,
17 base_url: String,
18}
19
20impl RedashClient {
21 pub fn new(base_url: String, api_key: &str) -> Result<Self> {
22 let mut headers = header::HeaderMap::new();
23 headers.insert(
24 "Authorization",
25 header::HeaderValue::from_str(&format!("Key {api_key}"))
26 .context("Invalid API key format")?,
27 );
28
29 let client = Client::builder()
30 .default_headers(headers)
31 .build()
32 .context("Failed to build HTTP client")?;
33
34 Ok(Self { client, base_url })
35 }
36
37 #[must_use]
38 pub fn base_url(&self) -> &str {
39 &self.base_url
40 }
41
42 async fn get_json<T: DeserializeOwned>(&self, url: &str, ctx: &str) -> Result<T> {
43 let response = self
44 .client
45 .get(url)
46 .send()
47 .await
48 .with_context(|| format!("Failed to request {ctx}"))?;
49
50 let response = ensure_success(response).await?;
51
52 response
53 .json()
54 .await
55 .with_context(|| format!("Failed to parse {ctx} response"))
56 }
57
58 async fn post_json<T: DeserializeOwned, B: Serialize + ?Sized>(
59 &self,
60 url: &str,
61 body: &B,
62 ctx: &str,
63 ) -> Result<T> {
64 let response = self
65 .client
66 .post(url)
67 .json(body)
68 .send()
69 .await
70 .with_context(|| format!("Failed to request {ctx}"))?;
71
72 let response = ensure_success(response).await?;
73
74 response
75 .json()
76 .await
77 .with_context(|| format!("Failed to parse {ctx} response"))
78 }
79
80 pub async fn list_my_queries(&self, page: u32, page_size: u32) -> Result<QueriesResponse> {
81 let url = format!(
82 "{}/api/queries/my?page={page}&page_size={page_size}",
83 self.base_url
84 );
85 self.get_json(&url, "my queries").await
86 }
87
88 pub async fn get_query(&self, query_id: u64) -> Result<Query> {
89 let url = format!("{}/api/queries/{query_id}", self.base_url);
90 self.get_json(&url, &format!("query {query_id}")).await
91 }
92
93 pub async fn list_data_sources(&self) -> Result<Vec<DataSource>> {
94 let url = format!("{}/api/data_sources", self.base_url);
95 self.get_json(&url, "data sources").await
96 }
97
98 pub async fn get_data_source(&self, data_source_id: u64) -> Result<DataSource> {
99 let url = format!("{}/api/data_sources/{data_source_id}", self.base_url);
100 self.get_json(&url, &format!("data source {data_source_id}"))
101 .await
102 }
103
104 pub async fn get_data_source_schema(
105 &self,
106 data_source_id: u64,
107 refresh: bool,
108 ) -> Result<DataSourceSchema> {
109 let url = if refresh {
110 format!(
111 "{}/api/data_sources/{data_source_id}/schema?refresh=true",
112 self.base_url
113 )
114 } else {
115 format!("{}/api/data_sources/{data_source_id}/schema", self.base_url)
116 };
117
118 self.get_json(&url, &format!("schema for data source {data_source_id}"))
119 .await
120 }
121
122 pub async fn create_query(&self, create_query: &CreateQuery) -> Result<Query> {
123 let url = format!("{}/api/queries", self.base_url);
124 self.post_json(&url, create_query, "new query").await
125 }
126
127 pub async fn create_or_update_query(&self, query: &Query) -> Result<Query> {
128 let url = format!("{}/api/queries/{}", self.base_url, query.id);
129 self.post_json(&url, query, &format!("query {} update", query.id))
130 .await
131 }
132
133 pub async fn create_visualization(
134 &self,
135 query_id: u64,
136 viz: &crate::models::CreateVisualization,
137 ) -> Result<crate::models::Visualization> {
138 let url = format!("{}/api/visualizations", self.base_url);
139 self.post_json(&url, viz, &format!("visualization for query {query_id}"))
140 .await
141 }
142
143 pub async fn update_visualization(
144 &self,
145 viz: &crate::models::Visualization,
146 ) -> Result<crate::models::Visualization> {
147 let url = format!("{}/api/visualizations/{}", self.base_url, viz.id);
148 self.post_json(&url, viz, &format!("visualization {} update", viz.id))
149 .await
150 }
151
152 pub async fn fetch_all_queries(&self) -> Result<Vec<Query>> {
153 let mut all_queries = Vec::new();
154 let mut page = 1;
155 let page_size = 100;
156
157 loop {
158 let response = self.list_my_queries(page, page_size).await?;
159
160 if response.results.is_empty() {
161 break;
162 }
163
164 all_queries.extend(response.results);
165 eprintln!(
166 "Fetched {} / {} queries...",
167 all_queries.len(),
168 response.count
169 );
170
171 #[allow(clippy::cast_possible_truncation)]
172 if all_queries.len() >= response.count as usize {
173 break;
174 }
175
176 page += 1;
177 }
178
179 Ok(all_queries)
180 }
181
182 pub async fn refresh_query(
183 &self,
184 query_id: u64,
185 parameters: Option<std::collections::HashMap<String, serde_json::Value>>,
186 ) -> Result<crate::models::Job> {
187 let url = format!("{}/api/queries/{query_id}/results", self.base_url);
188
189 let request = crate::models::RefreshRequest {
190 max_age: 0,
191 parameters,
192 };
193
194 let job_response: crate::models::JobResponse = self
195 .post_json(&url, &request, &format!("query {query_id} refresh"))
196 .await?;
197
198 Ok(job_response.job)
199 }
200
201 pub async fn poll_job(&self, job_id: &str) -> Result<crate::models::Job> {
202 let url = format!("{}/api/jobs/{job_id}", self.base_url);
203
204 let job_response: crate::models::JobResponse =
205 self.get_json(&url, &format!("job {job_id}")).await?;
206
207 Ok(job_response.job)
208 }
209
210 pub async fn cancel_job(&self, job_id: &str) -> Result<()> {
211 let url = format!("{}/api/jobs/{job_id}", self.base_url);
212 let response = self
213 .client
214 .delete(&url)
215 .send()
216 .await
217 .context(format!("Failed to cancel job {job_id}"))?;
218
219 ensure_success(response).await?;
220
221 Ok(())
222 }
223
224 async fn fetch_query_result(
225 &self,
226 url: &str,
227 fetch_context: &str,
228 ) -> Result<crate::models::QueryResult> {
229 let result_response: crate::models::QueryResultResponse =
230 self.get_json(url, fetch_context).await?;
231
232 Ok(result_response.query_result)
233 }
234
235 pub async fn get_query_result(
236 &self,
237 query_id: u64,
238 result_id: u64,
239 ) -> Result<crate::models::QueryResult> {
240 let url = format!(
241 "{}/api/queries/{query_id}/results/{result_id}.json",
242 self.base_url
243 );
244
245 self.fetch_query_result(&url, &format!("result {result_id} for query {query_id}"))
246 .await
247 }
248
249 pub async fn refresh_adhoc_query(
250 &self,
251 sql: &str,
252 data_source_id: u64,
253 parameters: Option<std::collections::HashMap<String, serde_json::Value>>,
254 ) -> Result<crate::models::Job> {
255 let url = format!("{}/api/query_results", self.base_url);
256
257 let request = crate::models::AdhocRefreshRequest {
258 sql: sql.to_string(),
259 data_source_id,
260 max_age: 0,
261 parameters,
262 };
263
264 let job_response: crate::models::JobResponse = self
265 .post_json(&url, &request, "ad-hoc query refresh")
266 .await?;
267
268 Ok(job_response.job)
269 }
270
271 pub async fn get_adhoc_query_result(
272 &self,
273 result_id: u64,
274 ) -> Result<crate::models::QueryResult> {
275 let url = format!("{}/api/query_results/{result_id}.json", self.base_url);
276
277 self.fetch_query_result(&url, &format!("ad-hoc result {result_id}"))
278 .await
279 }
280
281 async fn poll_job_to_completion(
282 &self,
283 job: crate::models::Job,
284 timeout_secs: u64,
285 poll_interval_ms: u64,
286 ) -> Result<u64> {
287 let job_id = job.id.clone();
288
289 let outcome = tokio::select! {
290 res = self.poll_loop(job, timeout_secs, poll_interval_ms) => res,
291 () = wait_for_ctrl_c() => Err(anyhow::anyhow!("Interrupted; cancelling query")),
292 };
293
294 if outcome.is_err() {
298 eprintln!("\nCancelling job {job_id}...");
299 if let Err(e) = self.cancel_job(&job_id).await {
300 eprintln!("Warning: failed to cancel job {job_id}: {e}");
301 }
302 }
303
304 outcome
305 }
306
307 async fn poll_loop(
308 &self,
309 job: crate::models::Job,
310 timeout_secs: u64,
311 poll_interval_ms: u64,
312 ) -> Result<u64> {
313 use crate::models::JobStatus;
314 use tokio::time::{Duration, sleep};
315
316 let start = std::time::Instant::now();
317 let timeout = Duration::from_secs(timeout_secs);
318 let poll_interval = Duration::from_millis(poll_interval_ms);
319
320 let mut current_job = job;
321 loop {
322 if start.elapsed() > timeout {
323 anyhow::bail!("Query execution timed out after {timeout_secs} seconds");
324 }
325
326 match JobStatus::from_u8(current_job.status)? {
327 JobStatus::Success => {
328 return current_job
329 .query_result_id
330 .context("Job succeeded but no result_id returned");
331 }
332 JobStatus::Failure => {
333 let error = current_job
334 .error
335 .unwrap_or_else(|| "Unknown error".to_string());
336 anyhow::bail!("Query execution failed: {error}");
337 }
338 JobStatus::Cancelled => {
339 anyhow::bail!("Query execution was cancelled");
340 }
341 JobStatus::Pending | JobStatus::Started => {
342 eprint!(".");
343 sleep(poll_interval).await;
344 current_job = self.poll_job(¤t_job.id).await?;
345 }
346 }
347 }
348 }
349
350 pub async fn execute_query_with_polling(
351 &self,
352 query_id: u64,
353 parameters: Option<std::collections::HashMap<String, serde_json::Value>>,
354 timeout_secs: u64,
355 poll_interval_ms: u64,
356 ) -> Result<crate::models::QueryResult> {
357 eprintln!("Executing query {query_id}...");
358 let job = self.refresh_query(query_id, parameters).await?;
359 let result_id = self
360 .poll_job_to_completion(job, timeout_secs, poll_interval_ms)
361 .await?;
362
363 eprintln!("Query completed, fetching results...");
364 self.get_query_result(query_id, result_id).await
365 }
366
367 pub async fn execute_adhoc_with_polling(
368 &self,
369 sql: &str,
370 data_source_id: u64,
371 parameters: Option<std::collections::HashMap<String, serde_json::Value>>,
372 timeout_secs: u64,
373 poll_interval_ms: u64,
374 ) -> Result<crate::models::QueryResult> {
375 eprintln!("Executing SQL on data source {data_source_id}...");
376 let job = self
377 .refresh_adhoc_query(sql, data_source_id, parameters)
378 .await?;
379 let result_id = self
380 .poll_job_to_completion(job, timeout_secs, poll_interval_ms)
381 .await?;
382
383 eprintln!("Query completed, fetching results...");
384 self.get_adhoc_query_result(result_id).await
385 }
386
387 pub async fn archive_query(&self, query_id: u64) -> Result<Query> {
388 let url = format!("{}/api/queries/{query_id}", self.base_url);
389 let payload = serde_json::json!({"is_archived": true});
390 self.post_json(&url, &payload, &format!("query {query_id} archive"))
391 .await
392 }
393
394 pub async fn unarchive_query(&self, query_id: u64) -> Result<Query> {
395 let url = format!("{}/api/queries/{query_id}", self.base_url);
396 let payload = serde_json::json!({"is_archived": false});
397 self.post_json(&url, &payload, &format!("query {query_id} unarchive"))
398 .await
399 }
400
401 pub async fn list_query_snippets(&self) -> Result<Vec<QuerySnippet>> {
402 let url = format!("{}/api/query_snippets", self.base_url);
403 self.get_json(&url, "query snippets").await
404 }
405
406 pub async fn get_query_snippet(&self, snippet_id: u64) -> Result<QuerySnippet> {
407 let url = format!("{}/api/query_snippets/{snippet_id}", self.base_url);
408 self.get_json(&url, &format!("query snippet {snippet_id}"))
409 .await
410 }
411
412 pub async fn create_query_snippet(&self, create: &CreateQuerySnippet) -> Result<QuerySnippet> {
413 let url = format!("{}/api/query_snippets", self.base_url);
414 self.post_json(&url, create, "new query snippet").await
415 }
416
417 pub async fn update_query_snippet(&self, snippet: &QuerySnippet) -> Result<QuerySnippet> {
418 let url = format!("{}/api/query_snippets/{}", self.base_url, snippet.id);
419 self.post_json(
420 &url,
421 snippet,
422 &format!("query snippet {} update", snippet.id),
423 )
424 .await
425 }
426
427 pub async fn delete_query_snippet(&self, snippet_id: u64) -> Result<()> {
428 let url = format!("{}/api/query_snippets/{snippet_id}", self.base_url);
429 let response = self
430 .client
431 .delete(&url)
432 .send()
433 .await
434 .context(format!("Failed to delete query snippet {snippet_id}"))?;
435
436 ensure_success(response).await?;
437
438 Ok(())
439 }
440
441 pub async fn create_dashboard(&self, dashboard: &CreateDashboard) -> Result<Dashboard> {
442 let url = format!("{}/api/dashboards", self.base_url);
443 self.post_json(&url, dashboard, "new dashboard").await
444 }
445
446 pub async fn list_favorite_dashboards(
447 &self,
448 page: u32,
449 page_size: u32,
450 ) -> Result<DashboardsResponse> {
451 let url = format!(
452 "{}/api/dashboards/favorites?page={page}&page_size={page_size}",
453 self.base_url
454 );
455 self.get_json(&url, "favorite dashboards").await
456 }
457
458 pub async fn get_dashboard(&self, slug_or_id: &str) -> Result<Dashboard> {
459 let url = format!("{}/api/dashboards/{slug_or_id}", self.base_url);
460 self.get_json(&url, &format!("dashboard {slug_or_id}"))
461 .await
462 }
463
464 pub async fn update_dashboard(&self, dashboard: &Dashboard) -> Result<Dashboard> {
465 let url = format!("{}/api/dashboards/{}", self.base_url, dashboard.id);
466 self.post_json(
467 &url,
468 dashboard,
469 &format!("dashboard {} update", dashboard.id),
470 )
471 .await
472 }
473
474 pub async fn archive_dashboard(&self, dashboard_id: u64) -> Result<()> {
475 let url = format!("{}/api/dashboards/{dashboard_id}", self.base_url);
476 let payload = serde_json::json!({"is_archived": true});
477 let response = self
478 .client
479 .post(&url)
480 .json(&payload)
481 .send()
482 .await
483 .context(format!("Failed to archive dashboard {dashboard_id}"))?;
484
485 ensure_success(response).await?;
486
487 Ok(())
488 }
489
490 pub async fn unarchive_dashboard(&self, dashboard_id: u64) -> Result<Dashboard> {
491 let url = format!("{}/api/dashboards/{dashboard_id}", self.base_url);
492 let payload = serde_json::json!({"is_archived": false});
493 self.post_json(
494 &url,
495 &payload,
496 &format!("dashboard {dashboard_id} unarchive"),
497 )
498 .await
499 }
500
501 pub async fn create_widget(&self, widget: &CreateWidget) -> Result<crate::models::Widget> {
502 let url = format!("{}/api/widgets", self.base_url);
503 self.post_json(&url, widget, "new widget").await
504 }
505
506 pub async fn update_widget(
507 &self,
508 widget_id: u64,
509 widget: &CreateWidget,
510 ) -> Result<crate::models::Widget> {
511 let url = format!("{}/api/widgets/{widget_id}", self.base_url);
512 self.post_json(&url, widget, &format!("widget {widget_id} update"))
513 .await
514 }
515
516 pub async fn delete_widget(&self, widget_id: u64) -> Result<()> {
517 let url = format!("{}/api/widgets/{widget_id}", self.base_url);
518 let response = self
519 .client
520 .delete(&url)
521 .send()
522 .await
523 .context(format!("Failed to delete widget {widget_id}"))?;
524
525 ensure_success(response).await?;
526
527 Ok(())
528 }
529
530 pub async fn favorite_dashboard(&self, slug: &str) -> Result<()> {
531 let url = format!("{}/api/dashboards/{slug}/favorite", self.base_url);
532 let response = self
533 .client
534 .post(&url)
535 .json(&serde_json::json!({}))
536 .send()
537 .await
538 .context(format!("Failed to favorite dashboard {slug}"))?;
539
540 ensure_success(response).await?;
541
542 Ok(())
543 }
544
545 pub async fn fetch_favorite_dashboards(&self) -> Result<Vec<DashboardSummary>> {
546 let mut all_dashboards = Vec::new();
547 let mut page = 1;
548 let page_size = 100;
549
550 loop {
551 let response = self.list_favorite_dashboards(page, page_size).await?;
552
553 if response.results.is_empty() {
554 break;
555 }
556
557 all_dashboards.extend(response.results);
558 eprintln!(
559 "Fetched {} / {} dashboards...",
560 all_dashboards.len(),
561 response.count
562 );
563
564 #[allow(clippy::cast_possible_truncation)]
565 if all_dashboards.len() >= response.count as usize {
566 break;
567 }
568
569 page += 1;
570 }
571
572 Ok(all_dashboards)
573 }
574
575 async fn get_with_retry(
576 &self,
577 url: &str,
578 params: &[(&str, String)],
579 ) -> Result<reqwest::Response> {
580 use tokio::time::{Duration, sleep};
581
582 const MAX_ATTEMPTS: u32 = 4;
583 let base_delays = [250u64, 500, 1000, 2000];
584
585 let mut last_error = anyhow::anyhow!("No attempts made");
586 for attempt in 0..MAX_ATTEMPTS {
587 let response = self
588 .client
589 .get(url)
590 .query(params)
591 .send()
592 .await
593 .with_context(|| format!("Failed to GET {url}"))?;
594
595 let status = response.status();
596 let should_retry = status.as_u16() == 429 || status.is_server_error();
597
598 match ensure_success(response).await {
599 Ok(response) => return Ok(response),
600 Err(err) if !should_retry || attempt + 1 == MAX_ATTEMPTS => return Err(err),
601 Err(err) => last_error = err,
602 }
603
604 let delay_ms = base_delays[attempt as usize];
605 sleep(Duration::from_millis(delay_ms)).await;
606 }
607
608 Err(last_error)
609 }
610
611 async fn list_queries(&self, q: &str, page: u32, page_size: u32) -> Result<QueriesResponse> {
612 let url = format!("{}/api/queries", self.base_url);
613 let params = [
614 ("page", page.to_string()),
615 ("page_size", page_size.to_string()),
616 ("q", q.to_string()),
617 ];
618 self.get_with_retry(&url, ¶ms)
619 .await?
620 .json()
621 .await
622 .context("Failed to parse queries response")
623 }
624
625 async fn list_dashboards(
626 &self,
627 q: &str,
628 page: u32,
629 page_size: u32,
630 ) -> Result<DashboardsResponse> {
631 let url = format!("{}/api/dashboards", self.base_url);
632 let params = [
633 ("page", page.to_string()),
634 ("page_size", page_size.to_string()),
635 ("q", q.to_string()),
636 ];
637 self.get_with_retry(&url, ¶ms)
638 .await?
639 .json()
640 .await
641 .context("Failed to parse dashboards response")
642 }
643
644 pub async fn search_queries(&self, q: &str, limit: usize) -> Result<Vec<Query>> {
645 const PAGE_SIZE: usize = 250;
646
647 let mut results: Vec<Query> = Vec::new();
648 let mut page = 1u32;
649
650 loop {
651 let remaining = limit - results.len();
652 #[allow(clippy::cast_possible_truncation)]
653 let page_size = remaining.min(PAGE_SIZE) as u32;
654 let response = self.list_queries(q, page, page_size).await?;
655
656 results.extend(response.results);
657
658 #[allow(clippy::cast_possible_truncation)]
659 if results.len() >= limit || results.len() >= response.count as usize {
660 break;
661 }
662
663 page += 1;
664 }
665
666 results.truncate(limit);
667 Ok(results)
668 }
669
670 pub async fn search_dashboards(&self, q: &str, limit: usize) -> Result<Vec<DashboardSummary>> {
671 const PAGE_SIZE: usize = 250;
672
673 let mut results: Vec<DashboardSummary> = Vec::new();
674 let mut page = 1u32;
675
676 loop {
677 let remaining = limit - results.len();
678 #[allow(clippy::cast_possible_truncation)]
679 let page_size = remaining.min(PAGE_SIZE) as u32;
680 let response = self.list_dashboards(q, page, page_size).await?;
681
682 results.extend(response.results);
683
684 #[allow(clippy::cast_possible_truncation)]
685 if results.len() >= limit || results.len() >= response.count as usize {
686 break;
687 }
688
689 page += 1;
690 }
691
692 results.truncate(limit);
693 Ok(results)
694 }
695}
696
697async fn ensure_success(response: reqwest::Response) -> Result<reqwest::Response> {
698 let status = response.status();
699 if !status.is_success() {
700 let body = response.text().await.unwrap_or_default();
701 anyhow::bail!("API error {status}: {body}");
702 }
703 Ok(response)
704}
705
706async fn wait_for_ctrl_c() {
707 let _ = tokio::signal::ctrl_c().await;
708}