Skip to main content

stmo_cli/
api.rs

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// `reqwest::Client` is internally `Arc`-ed, so cloning is cheap — this lets
13// `deploy` fan a bounded number of concurrent per-query GETs out across tasks.
14#[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        // Best-effort: a job that reached Success needs no cancellation, and cancelling
295        // one that already reached Failure/Cancelled is a harmless no-op. A failed
296        // cancel must never mask the original timeout/interrupt/poll error.
297        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(&current_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, &params)
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, &params)
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}