metabase_api_rs/api/
client.rs

1//! Metabase client implementation
2
3use crate::api::auth::{AuthManager, Credentials};
4use crate::api::CardListParams;
5use crate::core::error::{Error, Result};
6use crate::core::models::common::{ExportFormat, UserId};
7#[cfg(feature = "query-builder")]
8use crate::core::models::mbql::MbqlQuery;
9use crate::core::models::{
10    Card, Collection, Dashboard, DatabaseMetadata, DatasetQuery, Field, HealthStatus, MetabaseId,
11    NativeQuery, Pagination, QueryResult, SyncResult, User,
12};
13use crate::transport::HttpClient;
14use secrecy::ExposeSecret;
15use serde::Deserialize;
16use serde_json::{json, Value};
17use std::collections::HashMap;
18
19#[cfg(feature = "cache")]
20use crate::cache::{cache_key, CacheConfig, CacheLayer};
21
22/// The main client for interacting with Metabase API
23#[derive(Debug, Clone)]
24pub struct MetabaseClient {
25    pub(super) http_client: HttpClient,
26    pub(super) auth_manager: AuthManager,
27    pub(super) base_url: String,
28    #[cfg(feature = "cache")]
29    pub(super) cache: CacheLayer,
30}
31
32impl MetabaseClient {
33    /// Creates a new MetabaseClient instance
34    pub fn new(base_url: impl Into<String>) -> Result<Self> {
35        let base_url = base_url.into();
36
37        // Validate URL
38        if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
39            return Err(Error::Config(
40                "Invalid URL: must start with http:// or https://".to_string(),
41            ));
42        }
43
44        let http_client = HttpClient::new(&base_url)?;
45        let auth_manager = AuthManager::new();
46
47        Ok(Self {
48            http_client,
49            auth_manager,
50            base_url,
51            #[cfg(feature = "cache")]
52            cache: CacheLayer::new(CacheConfig::default()),
53        })
54    }
55
56    /// Creates a new MetabaseClient with custom cache configuration
57    #[cfg(feature = "cache")]
58    pub fn with_cache(base_url: impl Into<String>, cache_config: CacheConfig) -> Result<Self> {
59        let base_url = base_url.into();
60
61        // Validate URL
62        if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
63            return Err(Error::Config(
64                "Invalid URL: must start with http:// or https://".to_string(),
65            ));
66        }
67
68        let http_client = HttpClient::new(&base_url)?;
69        let auth_manager = AuthManager::new();
70
71        Ok(Self {
72            http_client,
73            auth_manager,
74            base_url,
75            cache: CacheLayer::new(cache_config),
76        })
77    }
78
79    /// Gets the base URL of the client
80    pub fn base_url(&self) -> &str {
81        &self.base_url
82    }
83
84    /// Checks if the client is authenticated
85    pub fn is_authenticated(&self) -> bool {
86        self.auth_manager.is_authenticated()
87    }
88
89    /// Checks if cache is enabled
90    #[cfg(feature = "cache")]
91    pub fn is_cache_enabled(&self) -> bool {
92        self.cache.is_enabled()
93    }
94
95    /// Sets the cache enabled state
96    #[cfg(feature = "cache")]
97    pub fn set_cache_enabled(&mut self, enabled: bool) {
98        self.cache.set_enabled(enabled);
99    }
100
101    /// Checks if cache is enabled (always false when cache feature is disabled)
102    #[cfg(not(feature = "cache"))]
103    pub fn is_cache_enabled(&self) -> bool {
104        false
105    }
106
107    /// Sets the cache enabled state (no-op when cache feature is disabled)
108    #[cfg(not(feature = "cache"))]
109    pub fn set_cache_enabled(&mut self, _enabled: bool) {
110        // No-op when cache feature is disabled
111    }
112
113    /// Authenticates with the Metabase API
114    pub async fn authenticate(&mut self, credentials: Credentials) -> Result<()> {
115        let request_body = match &credentials {
116            Credentials::EmailPassword { email, password } => {
117                json!({
118                    "username": email,
119                    "password": password.expose_secret()
120                })
121            }
122            Credentials::ApiKey { key } => {
123                json!({
124                    "api_key": key.expose_secret()
125                })
126            }
127        };
128
129        #[derive(Deserialize)]
130        struct SessionResponse {
131            id: String,
132            #[serde(flatten)]
133            user_data: serde_json::Value,
134        }
135
136        let response: SessionResponse =
137            self.http_client.post("/api/session", &request_body).await?;
138
139        // Parse user information
140        let user = User {
141            id: UserId(response.user_data["id"].as_i64().unwrap_or(1)),
142            email: response.user_data["email"]
143                .as_str()
144                .unwrap_or("unknown@example.com")
145                .to_string(),
146            first_name: response.user_data["first_name"]
147                .as_str()
148                .unwrap_or("Unknown")
149                .to_string(),
150            last_name: response.user_data["last_name"]
151                .as_str()
152                .unwrap_or("User")
153                .to_string(),
154            is_superuser: response.user_data["is_superuser"]
155                .as_bool()
156                .unwrap_or(false),
157            is_active: true,
158            is_qbnewb: response.user_data["is_qbnewb"].as_bool().unwrap_or(false),
159            date_joined: chrono::Utc::now(),
160            last_login: Some(chrono::Utc::now()),
161            common_name: None,
162            group_ids: Vec::new(),
163            locale: response.user_data["locale"].as_str().map(|s| s.to_string()),
164            google_auth: response.user_data["google_auth"].as_bool().unwrap_or(false),
165            ldap_auth: response.user_data["ldap_auth"].as_bool().unwrap_or(false),
166            login_attributes: None,
167            user_group_memberships: Vec::new(),
168        };
169
170        self.auth_manager.set_session(response.id, user);
171        Ok(())
172    }
173
174    /// Logs out from the Metabase API
175    pub async fn logout(&mut self) -> Result<()> {
176        if !self.is_authenticated() {
177            return Ok(());
178        }
179
180        // Send logout request
181        self.http_client.delete("/api/session").await?;
182
183        // Clear local session
184        self.auth_manager.clear_session();
185        Ok(())
186    }
187
188    /// Performs a health check on the Metabase API
189    pub async fn health_check(&self) -> Result<HealthStatus> {
190        self.http_client.get("/api/health").await
191    }
192
193    /// Gets the current authenticated user
194    pub async fn get_current_user(&self) -> Result<User> {
195        if !self.is_authenticated() {
196            return Err(Error::Authentication("Not authenticated".to_string()));
197        }
198
199        self.http_client.get("/api/user/current").await
200    }
201
202    // ==================== Card Operations ====================
203
204    /// Gets a card by ID
205    pub async fn get_card(&self, id: i64) -> Result<Card> {
206        #[cfg(feature = "cache")]
207        {
208            let cache_key = cache_key("card", id);
209            if let Some(card) = self.cache.get_metadata::<Card>(&cache_key) {
210                return Ok(card);
211            }
212        }
213
214        let path = format!("/api/card/{}", id);
215        let card: Card = self.http_client.get(&path).await?;
216
217        #[cfg(feature = "cache")]
218        {
219            let cache_key = cache_key("card", id);
220            let _ = self.cache.set_metadata(cache_key, &card);
221        }
222
223        Ok(card)
224    }
225
226    /// Lists all cards
227    pub async fn list_cards(&self, params: Option<CardListParams>) -> Result<Vec<Card>> {
228        let path = if let Some(p) = params {
229            let mut query_params = Vec::new();
230            if let Some(f) = p.f {
231                query_params.push(format!("f={}", f));
232            }
233            if let Some(model_type) = p.model_type {
234                query_params.push(format!("model_type={}", model_type));
235            }
236
237            if !query_params.is_empty() {
238                format!("/api/card?{}", query_params.join("&"))
239            } else {
240                "/api/card".to_string()
241            }
242        } else {
243            "/api/card".to_string()
244        };
245        self.http_client.get(&path).await
246    }
247
248    /// Creates a new card
249    pub async fn create_card(&self, card: Card) -> Result<Card> {
250        if !self.is_authenticated() {
251            return Err(Error::Authentication(
252                "Authentication required to create card".to_string(),
253            ));
254        }
255        self.http_client.post("/api/card", &card).await
256    }
257
258    /// Updates an existing card
259    pub async fn update_card(&self, id: i64, updates: serde_json::Value) -> Result<Card> {
260        if !self.is_authenticated() {
261            return Err(Error::Authentication(
262                "Authentication required to update card".to_string(),
263            ));
264        }
265
266        #[cfg(feature = "cache")]
267        {
268            let cache_key = cache_key("card", id);
269            self.cache.invalidate(&cache_key);
270        }
271
272        let path = format!("/api/card/{}", id);
273        self.http_client.put(&path, &updates).await
274    }
275
276    /// Deletes a card
277    pub async fn delete_card(&self, id: i64) -> Result<()> {
278        if !self.is_authenticated() {
279            return Err(Error::Authentication(
280                "Authentication required to delete card".to_string(),
281            ));
282        }
283
284        #[cfg(feature = "cache")]
285        {
286            let cache_key = cache_key("card", id);
287            self.cache.invalidate(&cache_key);
288        }
289
290        let path = format!("/api/card/{}", id);
291        self.http_client.delete(&path).await
292    }
293
294    // ==================== Collection Operations ====================
295
296    /// Gets a collection by ID
297    pub async fn get_collection(&self, id: MetabaseId) -> Result<Collection> {
298        #[cfg(feature = "cache")]
299        {
300            let cache_key = cache_key("collection", id.0);
301            if let Some(collection) = self.cache.get_metadata::<Collection>(&cache_key) {
302                return Ok(collection);
303            }
304        }
305
306        let path = format!("/api/collection/{}", id.0);
307        let collection: Collection = self.http_client.get(&path).await?;
308
309        #[cfg(feature = "cache")]
310        {
311            let cache_key = cache_key("collection", id.0);
312            let _ = self.cache.set_metadata(cache_key, &collection);
313        }
314
315        Ok(collection)
316    }
317
318    /// Lists all collections
319    pub async fn list_collections(&self) -> Result<Vec<Collection>> {
320        self.http_client.get("/api/collection").await
321    }
322
323    /// Creates a new collection
324    pub async fn create_collection(&self, collection: Collection) -> Result<Collection> {
325        if !self.is_authenticated() {
326            return Err(Error::Authentication(
327                "Authentication required to create collection".to_string(),
328            ));
329        }
330        self.http_client.post("/api/collection", &collection).await
331    }
332
333    /// Updates an existing collection
334    pub async fn update_collection(
335        &self,
336        id: MetabaseId,
337        updates: serde_json::Value,
338    ) -> Result<Collection> {
339        if !self.is_authenticated() {
340            return Err(Error::Authentication(
341                "Authentication required to update collection".to_string(),
342            ));
343        }
344
345        #[cfg(feature = "cache")]
346        {
347            let cache_key = cache_key("collection", id.0);
348            self.cache.invalidate(&cache_key);
349        }
350
351        let path = format!("/api/collection/{}", id.0);
352        self.http_client.put(&path, &updates).await
353    }
354
355    /// Archives a collection (Metabase doesn't delete, only archives)
356    pub async fn archive_collection(&self, id: MetabaseId) -> Result<Collection> {
357        if !self.is_authenticated() {
358            return Err(Error::Authentication(
359                "Authentication required to archive collection".to_string(),
360            ));
361        }
362
363        #[cfg(feature = "cache")]
364        {
365            let cache_key = cache_key("collection", id.0);
366            self.cache.invalidate(&cache_key);
367        }
368
369        let path = format!("/api/collection/{}", id.0);
370        let updates = json!({ "archived": true });
371        self.http_client.put(&path, &updates).await
372    }
373
374    // ==================== Dashboard Operations ====================
375
376    /// Gets a dashboard by ID
377    pub async fn get_dashboard(&self, id: MetabaseId) -> Result<Dashboard> {
378        #[cfg(feature = "cache")]
379        {
380            let cache_key = cache_key("dashboard", id.0);
381            if let Some(dashboard) = self.cache.get_metadata::<Dashboard>(&cache_key) {
382                return Ok(dashboard);
383            }
384        }
385
386        let path = format!("/api/dashboard/{}", id.0);
387        let dashboard: Dashboard = self.http_client.get(&path).await?;
388
389        #[cfg(feature = "cache")]
390        {
391            let cache_key = cache_key("dashboard", id.0);
392            let _ = self.cache.set_metadata(cache_key, &dashboard);
393        }
394
395        Ok(dashboard)
396    }
397
398    /// Lists all dashboards
399    pub async fn list_dashboards(&self, pagination: Option<Pagination>) -> Result<Vec<Dashboard>> {
400        let path = if let Some(p) = pagination {
401            format!("/api/dashboard?limit={}&offset={}", p.limit(), p.offset())
402        } else {
403            "/api/dashboard".to_string()
404        };
405        self.http_client.get(&path).await
406    }
407
408    /// Creates a new dashboard
409    pub async fn create_dashboard(&self, dashboard: Dashboard) -> Result<Dashboard> {
410        if !self.is_authenticated() {
411            return Err(Error::Authentication(
412                "Authentication required to create dashboard".to_string(),
413            ));
414        }
415        self.http_client.post("/api/dashboard", &dashboard).await
416    }
417
418    /// Updates an existing dashboard
419    pub async fn update_dashboard(
420        &self,
421        id: MetabaseId,
422        updates: serde_json::Value,
423    ) -> Result<Dashboard> {
424        if !self.is_authenticated() {
425            return Err(Error::Authentication(
426                "Authentication required to update dashboard".to_string(),
427            ));
428        }
429
430        #[cfg(feature = "cache")]
431        {
432            let cache_key = cache_key("dashboard", id.0);
433            self.cache.invalidate(&cache_key);
434        }
435
436        let path = format!("/api/dashboard/{}", id.0);
437        self.http_client.put(&path, &updates).await
438    }
439
440    /// Deletes a dashboard
441    pub async fn delete_dashboard(&self, id: MetabaseId) -> Result<()> {
442        if !self.is_authenticated() {
443            return Err(Error::Authentication(
444                "Authentication required to delete dashboard".to_string(),
445            ));
446        }
447
448        #[cfg(feature = "cache")]
449        {
450            let cache_key = cache_key("dashboard", id.0);
451            self.cache.invalidate(&cache_key);
452        }
453
454        let path = format!("/api/dashboard/{}", id.0);
455        self.http_client.delete(&path).await
456    }
457
458    // ==================== Query Operations ====================
459
460    /// Executes a dataset query
461    pub async fn execute_query(&self, query: DatasetQuery) -> Result<QueryResult> {
462        if !self.is_authenticated() {
463            return Err(Error::Authentication(
464                "Authentication required to execute query".to_string(),
465            ));
466        }
467        let request = json!({
468            "database": query.database.0,
469            "type": query.query_type,
470            "query": query.query,
471            "parameters": query.parameters
472        });
473        self.http_client.post("/api/dataset", &request).await
474    }
475
476    /// Executes a native SQL query
477    pub async fn execute_native_query(
478        &self,
479        database: MetabaseId,
480        native_query: NativeQuery,
481    ) -> Result<QueryResult> {
482        if !self.is_authenticated() {
483            return Err(Error::Authentication(
484                "Authentication required to execute native query".to_string(),
485            ));
486        }
487        let request = json!({
488            "database": database.0,
489            "type": "native",
490            "native": {
491                "query": native_query.query,
492                "template-tags": native_query.template_tags
493            }
494        });
495        self.http_client.post("/api/dataset", &request).await
496    }
497
498    // ==================== Extended Card Operations ====================
499
500    /// Execute a card's query with optional parameters
501    pub async fn execute_card_query(
502        &self,
503        card_id: i64,
504        parameters: Option<Value>,
505    ) -> Result<QueryResult> {
506        if !self.is_authenticated() {
507            return Err(Error::Authentication(
508                "Authentication required to execute card query".to_string(),
509            ));
510        }
511
512        let path = format!("/api/card/{}/query", card_id);
513        let request = if let Some(params) = parameters {
514            json!({ "parameters": params })
515        } else {
516            json!({})
517        };
518
519        self.http_client.post(&path, &request).await
520    }
521
522    /// Export card query results in specified format
523    pub async fn export_card_query(
524        &self,
525        card_id: i64,
526        format: ExportFormat,
527        parameters: Option<Value>,
528    ) -> Result<Vec<u8>> {
529        if !self.is_authenticated() {
530            return Err(Error::Authentication(
531                "Authentication required to export card query".to_string(),
532            ));
533        }
534
535        let path = format!("/api/card/{}/query/{}", card_id, format.as_str());
536        let request = if let Some(params) = parameters {
537            json!({ "parameters": params })
538        } else {
539            json!({})
540        };
541
542        self.http_client.post_binary(&path, &request).await
543    }
544
545    /// Execute a pivot query for a card
546    pub async fn execute_card_pivot_query(
547        &self,
548        card_id: i64,
549        parameters: Option<Value>,
550    ) -> Result<QueryResult> {
551        if !self.is_authenticated() {
552            return Err(Error::Authentication(
553                "Authentication required to execute pivot query".to_string(),
554            ));
555        }
556
557        let path = format!("/api/card/pivot/{}/query", card_id);
558        let request = if let Some(params) = parameters {
559            json!({ "parameters": params })
560        } else {
561            json!({})
562        };
563
564        self.http_client.post(&path, &request).await
565    }
566
567    // ==================== Database Metadata Operations ====================
568
569    /// Get database metadata including tables and fields
570    pub async fn get_database_metadata(&self, database_id: MetabaseId) -> Result<DatabaseMetadata> {
571        #[cfg(feature = "cache")]
572        {
573            let cache_key = cache_key("database_metadata", database_id.0);
574            if let Some(metadata) = self.cache.get_metadata::<DatabaseMetadata>(&cache_key) {
575                return Ok(metadata);
576            }
577        }
578
579        let path = format!("/api/database/{}/metadata", database_id.0);
580        let metadata: DatabaseMetadata = self.http_client.get(&path).await?;
581
582        #[cfg(feature = "cache")]
583        {
584            let cache_key = cache_key("database_metadata", database_id.0);
585            let _ = self.cache.set_metadata(cache_key, &metadata);
586        }
587
588        Ok(metadata)
589    }
590
591    /// Sync database schema
592    pub async fn sync_database_schema(&self, database_id: MetabaseId) -> Result<SyncResult> {
593        if !self.is_authenticated() {
594            return Err(Error::Authentication(
595                "Authentication required to sync database schema".to_string(),
596            ));
597        }
598
599        #[cfg(feature = "cache")]
600        {
601            let cache_key = cache_key("database_metadata", database_id.0);
602            self.cache.invalidate(&cache_key);
603        }
604
605        let path = format!("/api/database/{}/sync_schema", database_id.0);
606        self.http_client.post(&path, &json!({})).await
607    }
608
609    /// Get all fields for a database
610    pub async fn get_database_fields(&self, database_id: MetabaseId) -> Result<Vec<Field>> {
611        let path = format!("/api/database/{}/fields", database_id.0);
612        self.http_client.get(&path).await
613    }
614
615    /// Get all schemas for a database
616    pub async fn get_database_schemas(&self, database_id: MetabaseId) -> Result<Vec<String>> {
617        let path = format!("/api/database/{}/schemas", database_id.0);
618        self.http_client.get(&path).await
619    }
620
621    // ==================== Dataset Operations ====================
622
623    /// Execute a dataset query with advanced options
624    pub async fn execute_dataset_query(&self, query: Value) -> Result<QueryResult> {
625        if !self.is_authenticated() {
626            return Err(Error::Authentication(
627                "Authentication required to execute dataset query".to_string(),
628            ));
629        }
630
631        self.http_client.post("/api/dataset", &query).await
632    }
633
634    /// Execute a native query through the dataset endpoint
635    pub async fn execute_dataset_native(&self, query: Value) -> Result<QueryResult> {
636        if !self.is_authenticated() {
637            return Err(Error::Authentication(
638                "Authentication required to execute native dataset query".to_string(),
639            ));
640        }
641
642        self.http_client.post("/api/dataset/native", &query).await
643    }
644
645    /// Execute a pivot dataset query
646    pub async fn execute_dataset_pivot(&self, query: Value) -> Result<QueryResult> {
647        if !self.is_authenticated() {
648            return Err(Error::Authentication(
649                "Authentication required to execute pivot dataset query".to_string(),
650            ));
651        }
652
653        self.http_client.post("/api/dataset/pivot", &query).await
654    }
655
656    /// Export dataset query results
657    pub async fn export_dataset(&self, format: ExportFormat, query: Value) -> Result<Vec<u8>> {
658        if !self.is_authenticated() {
659            return Err(Error::Authentication(
660                "Authentication required to export dataset".to_string(),
661            ));
662        }
663
664        let path = format!("/api/dataset/{}", format.as_str());
665        self.http_client.post_binary(&path, &query).await
666    }
667
668    // ==================== MBQL Query Operations ====================
669
670    /// Execute an MBQL query
671    #[cfg(feature = "query-builder")]
672    pub async fn execute_mbql_query(
673        &self,
674        database_id: MetabaseId,
675        query: MbqlQuery,
676    ) -> Result<QueryResult> {
677        if !self.is_authenticated() {
678            return Err(Error::Authentication(
679                "Authentication required to execute MBQL query".to_string(),
680            ));
681        }
682
683        let dataset_query = query.to_dataset_query(database_id);
684        self.http_client.post("/api/dataset", &dataset_query).await
685    }
686
687    /// Export MBQL query results in specified format
688    #[cfg(feature = "query-builder")]
689    pub async fn export_mbql_query(
690        &self,
691        database_id: MetabaseId,
692        query: MbqlQuery,
693        format: ExportFormat,
694    ) -> Result<Vec<u8>> {
695        if !self.is_authenticated() {
696            return Err(Error::Authentication(
697                "Authentication required to export MBQL query".to_string(),
698            ));
699        }
700
701        let dataset_query = query.to_dataset_query(database_id);
702        let path = format!("/api/dataset/{}", format.as_str());
703        self.http_client.post_binary(&path, &dataset_query).await
704    }
705
706    // ==================== SQL Convenience Methods ====================
707
708    /// Execute a simple SQL query
709    pub async fn execute_sql(&self, database_id: MetabaseId, sql: &str) -> Result<QueryResult> {
710        let native_query = NativeQuery::new(sql);
711        self.execute_native_query(database_id, native_query).await
712    }
713
714    /// Execute a parameterized SQL query
715    pub async fn execute_sql_with_params(
716        &self,
717        database_id: MetabaseId,
718        sql: &str,
719        params: HashMap<String, Value>,
720    ) -> Result<QueryResult> {
721        let mut native_query = NativeQuery::new(sql);
722        for (name, value) in params {
723            native_query = native_query.with_param(&name, value);
724        }
725        self.execute_native_query(database_id, native_query).await
726    }
727
728    /// Export SQL query results in specified format
729    pub async fn export_sql_query(
730        &self,
731        database_id: MetabaseId,
732        sql: &str,
733        format: ExportFormat,
734    ) -> Result<Vec<u8>> {
735        if !self.is_authenticated() {
736            return Err(Error::Authentication(
737                "Authentication required to export SQL query".to_string(),
738            ));
739        }
740
741        let native_query = NativeQuery::new(sql);
742        let request = json!({
743            "database": database_id.0,
744            "type": "native",
745            "native": {
746                "query": native_query.query,
747                "template-tags": native_query.template_tags
748            }
749        });
750
751        let path = format!("/api/dataset/{}", format.as_str());
752        self.http_client.post_binary(&path, &request).await
753    }
754}