database_replicator/serendb/
client.rs

1// ABOUTME: HTTP client for SerenDB Console API
2// ABOUTME: Manages project settings like logical replication
3
4use anyhow::{Context, Result};
5use reqwest::Client;
6use serde::{Deserialize, Serialize};
7
8use crate::utils::replace_database_in_connection_string;
9
10/// Default SerenDB Console API base URL
11pub const DEFAULT_CONSOLE_API_URL: &str = "https://console.serendb.com";
12
13/// SerenDB Console API client
14pub struct ConsoleClient {
15    client: Client,
16    api_base_url: String,
17    api_key: String,
18}
19
20/// Project information from SerenDB Console API
21#[derive(Debug, Clone, Deserialize)]
22pub struct Project {
23    pub id: String,
24    pub name: String,
25    pub enable_logical_replication: bool,
26    #[serde(default)]
27    pub organization_id: Option<String>,
28}
29
30/// Branch information from SerenDB Console API
31#[allow(dead_code)]
32#[derive(Debug, Clone, Deserialize)]
33pub struct Branch {
34    pub id: String,
35    pub name: String,
36    pub project_id: String,
37    #[serde(default)]
38    pub is_default: bool,
39}
40
41/// Database information from SerenDB Console API
42#[allow(dead_code)]
43#[derive(Debug, Clone, Deserialize)]
44pub struct Database {
45    pub id: String,
46    pub name: String,
47    pub branch_id: String,
48}
49
50/// Connection string response payload
51#[allow(dead_code)]
52#[derive(Debug, Deserialize)]
53pub struct ConnectionStringResponse {
54    pub connection_string: String,
55}
56
57/// Request payload to create a database
58#[allow(dead_code)]
59#[derive(Debug, Serialize)]
60pub struct CreateDatabaseRequest {
61    pub name: String,
62}
63
64/// Paginated response wrapper from the Console API
65#[allow(dead_code)]
66#[derive(Debug, Deserialize)]
67pub struct PaginatedResponse<T> {
68    pub data: Vec<T>,
69    #[serde(default)]
70    pub pagination: Option<Pagination>,
71}
72
73/// Pagination metadata returned by the Console API
74#[allow(dead_code)]
75#[derive(Debug, Deserialize)]
76pub struct Pagination {
77    pub total: i64,
78    pub page: i64,
79    pub per_page: i64,
80}
81
82/// Wrapper for API responses
83#[derive(Debug, Deserialize)]
84pub struct DataResponse<T> {
85    pub data: T,
86}
87
88/// Request to update project settings
89#[derive(Debug, Serialize)]
90pub struct UpdateProjectRequest {
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub enable_logical_replication: Option<bool>,
93}
94
95impl ConsoleClient {
96    /// Create a new Console API client
97    ///
98    /// # Arguments
99    ///
100    /// * `api_base_url` - Optional base URL (defaults to https://console.serendb.com)
101    /// * `api_key` - SerenDB API key (format: seren_<key_id>_<secret>)
102    pub fn new(api_base_url: Option<&str>, api_key: String) -> Self {
103        Self {
104            client: Client::new(),
105            api_base_url: api_base_url
106                .unwrap_or(DEFAULT_CONSOLE_API_URL)
107                .trim_end_matches('/')
108                .to_string(),
109            api_key,
110        }
111    }
112
113    /// List all projects accessible to the authenticated user
114    ///
115    /// # Returns
116    ///
117    /// Vector of projects the user has access to
118    ///
119    /// # Examples
120    /// ```ignore
121    /// let client = ConsoleClient::new(None, "seren_key".to_string());
122    /// let projects = client.list_projects().await?;
123    /// for project in projects {
124    ///     println!("{}: {}", project.id, project.name);
125    /// }
126    /// ```
127    pub async fn list_projects(&self) -> Result<Vec<Project>> {
128        let url = format!("{}/api/projects", self.api_base_url);
129
130        let response = self
131            .client
132            .get(&url)
133            .header("Authorization", format!("Bearer {}", self.api_key))
134            .header("Content-Type", "application/json")
135            .send()
136            .await
137            .context("Failed to send request to SerenDB Console API")?;
138
139        self.handle_common_errors(&response).await?;
140
141        if !response.status().is_success() {
142            let status = response.status();
143            let body = response.text().await.unwrap_or_default();
144            anyhow::bail!("SerenDB Console API returned error {}: {}", status, body);
145        }
146
147        let data: PaginatedResponse<Project> = response
148            .json()
149            .await
150            .context("Failed to parse projects response from SerenDB Console API")?;
151
152        Ok(data.data)
153    }
154
155    /// List all branches for a project
156    pub async fn list_branches(&self, project_id: &str) -> Result<Vec<Branch>> {
157        let url = format!("{}/api/projects/{}/branches", self.api_base_url, project_id);
158
159        let response = self
160            .client
161            .get(&url)
162            .header("Authorization", format!("Bearer {}", self.api_key))
163            .header("Content-Type", "application/json")
164            .send()
165            .await
166            .context("Failed to send request to SerenDB Console API")?;
167
168        self.handle_common_errors(&response).await?;
169
170        if !response.status().is_success() {
171            let status = response.status();
172            let body = response.text().await.unwrap_or_default();
173            anyhow::bail!("SerenDB Console API returned error {}: {}", status, body);
174        }
175
176        let data: PaginatedResponse<Branch> = response
177            .json()
178            .await
179            .context("Failed to parse branches response from SerenDB Console API")?;
180
181        Ok(data.data)
182    }
183
184    /// Get the default branch for a project
185    ///
186    /// Returns the branch marked as default, or the first branch if none are marked.
187    pub async fn get_default_branch(&self, project_id: &str) -> Result<Branch> {
188        let branches = self.list_branches(project_id).await?;
189        select_default_branch(project_id, branches)
190    }
191
192    /// List all databases within a SerenDB branch
193    pub async fn list_databases(&self, project_id: &str, branch_id: &str) -> Result<Vec<Database>> {
194        let url = format!(
195            "{}/api/projects/{}/branches/{}/databases",
196            self.api_base_url, project_id, branch_id
197        );
198
199        let response = self
200            .client
201            .get(&url)
202            .header("Authorization", format!("Bearer {}", self.api_key))
203            .header("Content-Type", "application/json")
204            .send()
205            .await
206            .context("Failed to send request to SerenDB Console API")?;
207
208        self.handle_common_errors(&response).await?;
209
210        if !response.status().is_success() {
211            let status = response.status();
212            let body = response.text().await.unwrap_or_default();
213            anyhow::bail!("SerenDB Console API returned error {}: {}", status, body);
214        }
215
216        let data: PaginatedResponse<Database> = response
217            .json()
218            .await
219            .context("Failed to parse databases response from SerenDB Console API")?;
220
221        Ok(data.data)
222    }
223
224    /// Create a new SerenDB database inside a branch
225    pub async fn create_database(
226        &self,
227        project_id: &str,
228        branch_id: &str,
229        name: &str,
230    ) -> Result<Database> {
231        let url = format!(
232            "{}/api/projects/{}/branches/{}/databases",
233            self.api_base_url, project_id, branch_id
234        );
235
236        let request = CreateDatabaseRequest {
237            name: name.to_string(),
238        };
239
240        let response = self
241            .client
242            .post(&url)
243            .header("Authorization", format!("Bearer {}", self.api_key))
244            .header("Content-Type", "application/json")
245            .json(&request)
246            .send()
247            .await
248            .context("Failed to send request to SerenDB Console API")?;
249
250        self.handle_common_errors(&response).await?;
251
252        if !response.status().is_success() {
253            let status = response.status();
254            let body = response.text().await.unwrap_or_default();
255            anyhow::bail!(
256                "Failed to create database '{}': {} - {}",
257                name,
258                status,
259                body
260            );
261        }
262
263        let data: DataResponse<Database> = response
264            .json()
265            .await
266            .context("Failed to parse create database response from SerenDB Console API")?;
267
268        Ok(data.data)
269    }
270
271    /// Get a connection string for a branch/database combination
272    pub async fn get_connection_string(
273        &self,
274        project_id: &str,
275        branch_id: &str,
276        database: &str,
277        pooled: bool,
278    ) -> Result<String> {
279        let url = format!(
280            "{}/api/projects/{}/branches/{}/connection-string?pooled={}",
281            self.api_base_url, project_id, branch_id, pooled
282        );
283
284        let response = self
285            .client
286            .get(&url)
287            .header("Authorization", format!("Bearer {}", self.api_key))
288            .header("Content-Type", "application/json")
289            .send()
290            .await
291            .context("Failed to send request to SerenDB Console API")?;
292
293        self.handle_common_errors(&response).await?;
294
295        if !response.status().is_success() {
296            let status = response.status();
297            let body = response.text().await.unwrap_or_default();
298            anyhow::bail!("SerenDB Console API returned error {}: {}", status, body);
299        }
300
301        let data: ConnectionStringResponse = response
302            .json()
303            .await
304            .context("Failed to parse connection string response from SerenDB Console API")?;
305
306        replace_database_in_connection_string(&data.connection_string, database)
307    }
308
309    /// Get project information by ID
310    ///
311    /// # Arguments
312    ///
313    /// * `project_id` - UUID string of the project
314    ///
315    /// # Returns
316    ///
317    /// Project information including logical replication status
318    pub async fn get_project(&self, project_id: &str) -> Result<Project> {
319        let url = format!("{}/api/projects/{}", self.api_base_url, project_id);
320
321        let response = self
322            .client
323            .get(&url)
324            .header("Authorization", format!("Bearer {}", self.api_key))
325            .header("Content-Type", "application/json")
326            .send()
327            .await
328            .context("Failed to send request to SerenDB Console API")?;
329
330        self.handle_common_errors_with_context(
331            &response,
332            Some(format!(
333                "Project {} not found.\n\
334                 Verify the project ID is correct and you have access to it.",
335                project_id
336            )),
337        )
338        .await?;
339
340        if !response.status().is_success() {
341            let status = response.status();
342            let body = response.text().await.unwrap_or_default();
343            anyhow::bail!("SerenDB Console API returned error {}: {}", status, body);
344        }
345
346        let data: DataResponse<Project> = response
347            .json()
348            .await
349            .context("Failed to parse project response from SerenDB Console API")?;
350
351        Ok(data.data)
352    }
353
354    /// Enable logical replication for a project
355    ///
356    /// **Warning**: This action cannot be undone. Once enabled, logical replication
357    /// cannot be disabled. Enabling will briefly suspend all active endpoints.
358    ///
359    /// # Arguments
360    ///
361    /// * `project_id` - UUID string of the project
362    ///
363    /// # Returns
364    ///
365    /// Updated project information
366    pub async fn enable_logical_replication(&self, project_id: &str) -> Result<Project> {
367        let url = format!("{}/api/projects/{}", self.api_base_url, project_id);
368
369        let request = UpdateProjectRequest {
370            enable_logical_replication: Some(true),
371        };
372
373        let response = self
374            .client
375            .patch(&url)
376            .header("Authorization", format!("Bearer {}", self.api_key))
377            .header("Content-Type", "application/json")
378            .json(&request)
379            .send()
380            .await
381            .context("Failed to send request to SerenDB Console API")?;
382
383        self.handle_common_errors_with_context(
384            &response,
385            Some(format!(
386                "Project {} not found.\n\
387                 Verify the project ID is correct and you have access to it.",
388                project_id
389            )),
390        )
391        .await?;
392
393        if !response.status().is_success() {
394            let status = response.status();
395            let body = response.text().await.unwrap_or_default();
396            anyhow::bail!(
397                "Failed to enable logical replication. SerenDB Console API returned {}: {}",
398                status,
399                body
400            );
401        }
402
403        let data: DataResponse<Project> = response
404            .json()
405            .await
406            .context("Failed to parse project response from SerenDB Console API")?;
407
408        Ok(data.data)
409    }
410
411    /// Check if logical replication is enabled for a project
412    ///
413    /// # Arguments
414    ///
415    /// * `project_id` - UUID string of the project
416    ///
417    /// # Returns
418    ///
419    /// true if logical replication is enabled, false otherwise
420    pub async fn is_logical_replication_enabled(&self, project_id: &str) -> Result<bool> {
421        let project = self.get_project(project_id).await?;
422        Ok(project.enable_logical_replication)
423    }
424
425    async fn handle_common_errors(&self, response: &reqwest::Response) -> Result<()> {
426        self.handle_common_errors_with_context(response, None).await
427    }
428
429    async fn handle_common_errors_with_context(
430        &self,
431        response: &reqwest::Response,
432        not_found_message: Option<String>,
433    ) -> Result<()> {
434        if response.status() == reqwest::StatusCode::UNAUTHORIZED {
435            anyhow::bail!(
436                "SerenDB API key is invalid or expired.\n\
437                 Generate a new key at: https://console.serendb.com/api-keys"
438            );
439        }
440
441        if response.status() == reqwest::StatusCode::NOT_FOUND {
442            if let Some(message) = not_found_message {
443                anyhow::bail!(message);
444            } else {
445                anyhow::bail!("Resource not found. Verify the ID is correct and you have access.");
446            }
447        }
448
449        Ok(())
450    }
451}
452
453fn select_default_branch(project_id: &str, branches: Vec<Branch>) -> Result<Branch> {
454    if branches.is_empty() {
455        anyhow::bail!("Project {} has no branches", project_id);
456    }
457
458    if let Some(default_branch) = branches.iter().find(|branch| branch.is_default) {
459        return Ok(default_branch.clone());
460    }
461
462    Ok(branches.into_iter().next().expect("branches is not empty"))
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    #[test]
470    fn test_client_creation() {
471        let client = ConsoleClient::new(None, "seren_test_key".to_string());
472        assert_eq!(client.api_base_url, DEFAULT_CONSOLE_API_URL);
473    }
474
475    #[test]
476    fn test_client_custom_url() {
477        let client = ConsoleClient::new(
478            Some("https://custom.serendb.com/"),
479            "seren_test_key".to_string(),
480        );
481        assert_eq!(client.api_base_url, "https://custom.serendb.com");
482    }
483
484    #[test]
485    fn test_update_request_serialization() {
486        let request = UpdateProjectRequest {
487            enable_logical_replication: Some(true),
488        };
489        let json = serde_json::to_string(&request).unwrap();
490        assert!(json.contains("enable_logical_replication"));
491        assert!(json.contains("true"));
492    }
493
494    #[test]
495    fn test_branch_deserialization() {
496        let json = r#"{"id": "abc", "name": "main", "project_id": "xyz", "is_default": true}"#;
497        let branch: Branch = serde_json::from_str(json).unwrap();
498        assert_eq!(branch.name, "main");
499        assert!(branch.is_default);
500    }
501
502    #[test]
503    fn test_database_deserialization() {
504        let json = r#"{"id": "db1", "name": "myapp", "branch_id": "br1"}"#;
505        let db: Database = serde_json::from_str(json).unwrap();
506        assert_eq!(db.name, "myapp");
507        assert_eq!(db.branch_id, "br1");
508    }
509
510    #[test]
511    fn test_select_default_branch_prefers_flagged_branch() {
512        let branches = vec![
513            Branch {
514                id: "br1".into(),
515                name: "preview".into(),
516                project_id: "proj".into(),
517                is_default: false,
518            },
519            Branch {
520                id: "br2".into(),
521                name: "main".into(),
522                project_id: "proj".into(),
523                is_default: true,
524            },
525        ];
526
527        let default = select_default_branch("proj", branches).unwrap();
528        assert_eq!(default.id, "br2");
529        assert_eq!(default.name, "main");
530    }
531
532    #[test]
533    fn test_select_default_branch_falls_back_to_first() {
534        let branches = vec![
535            Branch {
536                id: "br1".into(),
537                name: "alpha".into(),
538                project_id: "proj".into(),
539                is_default: false,
540            },
541            Branch {
542                id: "br2".into(),
543                name: "beta".into(),
544                project_id: "proj".into(),
545                is_default: false,
546            },
547        ];
548
549        let default = select_default_branch("proj", branches).unwrap();
550        assert_eq!(default.id, "br1");
551        assert_eq!(default.name, "alpha");
552    }
553
554    #[test]
555    fn test_select_default_branch_errors_when_empty() {
556        let err = select_default_branch("proj", Vec::new()).unwrap_err();
557        assert!(format!("{err}").contains("has no branches"));
558    }
559
560    #[test]
561    fn test_replace_database_in_connection_string() {
562        let original =
563            "postgresql://user:pass@host.serendb.com:5432/serendb?sslmode=require&foo=bar";
564        let updated =
565            replace_database_in_connection_string(original, "myapp").expect("replace succeeds");
566        assert!(updated.contains("/myapp?"));
567        assert!(updated.starts_with("postgresql://user:pass@host.serendb.com:5432/"));
568        assert!(updated.ends_with("sslmode=require&foo=bar"));
569    }
570}