1use anyhow::{Context, Result};
5use reqwest::Client;
6use serde::{Deserialize, Serialize};
7
8use crate::utils::replace_database_in_connection_string;
9
10pub const DEFAULT_CONSOLE_API_URL: &str = "https://console.serendb.com";
12
13pub struct ConsoleClient {
15 client: Client,
16 api_base_url: String,
17 api_key: String,
18}
19
20#[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#[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#[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#[allow(dead_code)]
52#[derive(Debug, Deserialize)]
53pub struct ConnectionStringResponse {
54 pub connection_string: String,
55}
56
57#[allow(dead_code)]
59#[derive(Debug, Serialize)]
60pub struct CreateDatabaseRequest {
61 pub name: String,
62}
63
64#[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#[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#[derive(Debug, Deserialize)]
84pub struct DataResponse<T> {
85 pub data: T,
86}
87
88#[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 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 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 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 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 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 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 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 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 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 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}