Skip to main content

syncable_cli/platform/api/
client.rs

1//! Platform API client for Syncable
2//!
3//! Provides authenticated access to the Syncable Platform API for managing
4//! organizations, projects, and other platform resources.
5
6use super::error::{PlatformApiError, Result};
7use super::types::{
8    ApiErrorResponse, ArtifactRegistry, AvailableRepositoriesResponse, CloudCredentialStatus,
9    CloudProvider, CloudRunnerNetwork, ClusterEntity, ConnectRepositoryRequest,
10    ConnectRepositoryResponse, CreateDeploymentConfigRequest, CreateDeploymentConfigResponse,
11    CreateRegistryRequest, CreateRegistryResponse, DeploymentConfig, DeploymentSecretInput,
12    DeploymentTaskStatus, Environment, GenericResponse, GetLogsResponse,
13    GitHubInstallationUrlResponse, GitHubInstallationsResponse, InitializeGitOpsRequest,
14    InitializeGitOpsResponse, Organization, PaginatedDeployments, Project,
15    ProjectRepositoriesResponse, RegistryTaskStatus, TriggerDeploymentRequest,
16    TriggerDeploymentResponse, UserProfile,
17};
18use crate::auth::credentials;
19use reqwest::Client;
20use serde::Serialize;
21use serde::de::DeserializeOwned;
22use std::time::Duration;
23use urlencoding;
24
25/// Production API URL
26const SYNCABLE_API_URL_PROD: &str = "https://syncable.dev";
27/// Development API URL
28const SYNCABLE_API_URL_DEV: &str = "http://localhost:4000";
29
30/// User agent for API requests
31const USER_AGENT: &str = concat!("syncable-cli/", env!("CARGO_PKG_VERSION"));
32
33/// Maximum number of retry attempts for transient failures
34const MAX_RETRIES: u32 = 3;
35/// Initial backoff delay in milliseconds
36const INITIAL_BACKOFF_MS: u64 = 500;
37/// Maximum backoff delay in milliseconds
38const MAX_BACKOFF_MS: u64 = 5000;
39
40/// Check if an error is retryable (transient failure)
41fn is_retryable_error(error: &PlatformApiError) -> bool {
42    matches!(
43        error,
44        PlatformApiError::HttpError(_)      // Network errors, timeouts
45        | PlatformApiError::RateLimited     // 429 - rate limited
46        | PlatformApiError::ServerError { .. } // 5xx - server errors
47        | PlatformApiError::ConnectionFailed // Connection failures
48    )
49}
50
51/// Client for interacting with the Syncable Platform API
52pub struct PlatformApiClient {
53    /// HTTP client with configured timeout and headers
54    http_client: Client,
55    /// Base API URL
56    api_url: String,
57}
58
59impl PlatformApiClient {
60    /// Create a new Platform API client using the default API URL
61    ///
62    /// Uses `SYNCABLE_ENV=development` to switch to local development server.
63    pub fn new() -> Result<Self> {
64        let api_url = get_api_url();
65        Self::with_url(api_url)
66    }
67
68    /// Create a new Platform API client with a custom API URL
69    pub fn with_url(api_url: impl Into<String>) -> Result<Self> {
70        let http_client = Client::builder()
71            .timeout(Duration::from_secs(30))
72            .user_agent(USER_AGENT)
73            .build()
74            .map_err(PlatformApiError::HttpError)?;
75
76        Ok(Self {
77            http_client,
78            api_url: api_url.into(),
79        })
80    }
81
82    /// Get the configured API URL
83    pub fn api_url(&self) -> &str {
84        &self.api_url
85    }
86
87    /// Get the authentication token from stored credentials
88    fn get_auth_token() -> Result<String> {
89        credentials::get_access_token().ok_or(PlatformApiError::Unauthorized)
90    }
91
92    /// Make an authenticated GET request with automatic retry for transient failures
93    async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
94        let token = Self::get_auth_token()?;
95        let url = format!("{}{}", self.api_url, path);
96
97        let mut last_error = None;
98        let mut backoff_ms = INITIAL_BACKOFF_MS;
99
100        for attempt in 0..=MAX_RETRIES {
101            let result = self.http_client.get(&url).bearer_auth(&token).send().await;
102
103            match result {
104                Ok(response) => match self.handle_response(response).await {
105                    Ok(data) => return Ok(data),
106                    Err(e) if is_retryable_error(&e) && attempt < MAX_RETRIES => {
107                        eprintln!(
108                            "Request failed (attempt {}/{}), retrying in {}ms...",
109                            attempt + 1,
110                            MAX_RETRIES + 1,
111                            backoff_ms
112                        );
113                        last_error = Some(e);
114                        tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
115                        backoff_ms = (backoff_ms * 2).min(MAX_BACKOFF_MS);
116                    }
117                    Err(e) => return Err(e),
118                },
119                Err(e) => {
120                    let platform_error = PlatformApiError::HttpError(e);
121                    if is_retryable_error(&platform_error) && attempt < MAX_RETRIES {
122                        eprintln!(
123                            "Network error (attempt {}/{}), retrying in {}ms...",
124                            attempt + 1,
125                            MAX_RETRIES + 1,
126                            backoff_ms
127                        );
128                        last_error = Some(platform_error);
129                        tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
130                        backoff_ms = (backoff_ms * 2).min(MAX_BACKOFF_MS);
131                    } else {
132                        return Err(platform_error);
133                    }
134                }
135            }
136        }
137
138        Err(last_error.expect("retry loop should have set last_error"))
139    }
140
141    /// Make an authenticated GET request that returns Option<T>
142    /// Returns None for 404 responses instead of an error
143    /// Includes retry logic for transient failures
144    async fn get_optional<T: DeserializeOwned>(&self, path: &str) -> Result<Option<T>> {
145        let token = Self::get_auth_token()?;
146        let url = format!("{}{}", self.api_url, path);
147
148        let mut last_error = None;
149        let mut backoff_ms = INITIAL_BACKOFF_MS;
150
151        for attempt in 0..=MAX_RETRIES {
152            let result = self.http_client.get(&url).bearer_auth(&token).send().await;
153
154            match result {
155                Ok(response) => {
156                    let status = response.status();
157
158                    if status.is_success() {
159                        let result = response
160                            .json::<T>()
161                            .await
162                            .map_err(|e| PlatformApiError::ParseError(e.to_string()))?;
163                        return Ok(Some(result));
164                    } else if status.as_u16() == 404 {
165                        return Ok(None);
166                    } else {
167                        let status_code = status.as_u16();
168                        let error_body = response.text().await.unwrap_or_default();
169                        let error_message = serde_json::from_str::<ApiErrorResponse>(&error_body)
170                            .map(|e| e.get_message())
171                            .unwrap_or_else(|_| error_body.clone());
172
173                        let error = match status_code {
174                            401 => PlatformApiError::Unauthorized,
175                            403 => PlatformApiError::PermissionDenied(error_message),
176                            429 => PlatformApiError::RateLimited,
177                            500..=599 => PlatformApiError::ServerError {
178                                status: status_code,
179                                message: error_message,
180                            },
181                            _ => PlatformApiError::ApiError {
182                                status: status_code,
183                                message: error_message,
184                            },
185                        };
186
187                        if is_retryable_error(&error) && attempt < MAX_RETRIES {
188                            eprintln!(
189                                "Request failed (attempt {}/{}), retrying in {}ms...",
190                                attempt + 1,
191                                MAX_RETRIES + 1,
192                                backoff_ms
193                            );
194                            last_error = Some(error);
195                            tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
196                            backoff_ms = (backoff_ms * 2).min(MAX_BACKOFF_MS);
197                        } else {
198                            return Err(error);
199                        }
200                    }
201                }
202                Err(e) => {
203                    let platform_error = PlatformApiError::HttpError(e);
204                    if is_retryable_error(&platform_error) && attempt < MAX_RETRIES {
205                        eprintln!(
206                            "Network error (attempt {}/{}), retrying in {}ms...",
207                            attempt + 1,
208                            MAX_RETRIES + 1,
209                            backoff_ms
210                        );
211                        last_error = Some(platform_error);
212                        tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
213                        backoff_ms = (backoff_ms * 2).min(MAX_BACKOFF_MS);
214                    } else {
215                        return Err(platform_error);
216                    }
217                }
218            }
219        }
220
221        Err(last_error.expect("retry loop should have set last_error"))
222    }
223
224    /// Make an authenticated POST request with a JSON body
225    /// Only retries on network errors (before request completes), not on server responses,
226    /// since POST requests may not be idempotent.
227    async fn post<T: DeserializeOwned, B: Serialize>(&self, path: &str, body: &B) -> Result<T> {
228        let token = Self::get_auth_token()?;
229        let url = format!("{}{}", self.api_url, path);
230
231        let mut last_error = None;
232        let mut backoff_ms = INITIAL_BACKOFF_MS;
233
234        for attempt in 0..=MAX_RETRIES {
235            let result = self
236                .http_client
237                .post(&url)
238                .bearer_auth(&token)
239                .json(body)
240                .send()
241                .await;
242
243            match result {
244                Ok(response) => {
245                    // Got a response - don't retry POST even on server errors
246                    return self.handle_response(response).await;
247                }
248                Err(e) => {
249                    // Network error before request completed - safe to retry
250                    let platform_error = PlatformApiError::HttpError(e);
251                    if attempt < MAX_RETRIES {
252                        eprintln!(
253                            "Network error (attempt {}/{}), retrying in {}ms...",
254                            attempt + 1,
255                            MAX_RETRIES + 1,
256                            backoff_ms
257                        );
258                        last_error = Some(platform_error);
259                        tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
260                        backoff_ms = (backoff_ms * 2).min(MAX_BACKOFF_MS);
261                    } else {
262                        return Err(platform_error);
263                    }
264                }
265            }
266        }
267
268        Err(last_error.expect("retry loop should have set last_error"))
269    }
270
271    /// Make an authenticated PUT request with a JSON body
272    /// Only retries on network errors (before request completes), not on server responses,
273    /// since PUT requests may not be idempotent.
274    async fn put<T: DeserializeOwned, B: Serialize>(&self, path: &str, body: &B) -> Result<T> {
275        let token = Self::get_auth_token()?;
276        let url = format!("{}{}", self.api_url, path);
277
278        let mut last_error = None;
279        let mut backoff_ms = INITIAL_BACKOFF_MS;
280
281        for attempt in 0..=MAX_RETRIES {
282            let result = self
283                .http_client
284                .put(&url)
285                .bearer_auth(&token)
286                .json(body)
287                .send()
288                .await;
289
290            match result {
291                Ok(response) => {
292                    // Got a response - don't retry PUT even on server errors
293                    return self.handle_response(response).await;
294                }
295                Err(e) => {
296                    // Network error before request completed - safe to retry
297                    let platform_error = PlatformApiError::HttpError(e);
298                    if attempt < MAX_RETRIES {
299                        eprintln!(
300                            "Network error (attempt {}/{}), retrying in {}ms...",
301                            attempt + 1,
302                            MAX_RETRIES + 1,
303                            backoff_ms
304                        );
305                        last_error = Some(platform_error);
306                        tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
307                        backoff_ms = (backoff_ms * 2).min(MAX_BACKOFF_MS);
308                    } else {
309                        return Err(platform_error);
310                    }
311                }
312            }
313        }
314
315        Err(last_error.expect("retry loop should have set last_error"))
316    }
317
318    /// Handle the HTTP response, converting errors appropriately
319    async fn handle_response<T: DeserializeOwned>(&self, response: reqwest::Response) -> Result<T> {
320        let status = response.status();
321
322        if status.is_success() {
323            // Try to parse the response body
324            response
325                .json::<T>()
326                .await
327                .map_err(|e| PlatformApiError::ParseError(e.to_string()))
328        } else {
329            // Try to parse error response for better error messages
330            let status_code = status.as_u16();
331            let error_body = response.text().await.unwrap_or_default();
332            let error_message = serde_json::from_str::<ApiErrorResponse>(&error_body)
333                .map(|e| e.get_message())
334                .unwrap_or_else(|_| error_body.clone());
335
336            match status_code {
337                401 => Err(PlatformApiError::Unauthorized),
338                403 => Err(PlatformApiError::PermissionDenied(error_message)),
339                404 => Err(PlatformApiError::NotFound(error_message)),
340                429 => Err(PlatformApiError::RateLimited),
341                500..=599 => Err(PlatformApiError::ServerError {
342                    status: status_code,
343                    message: error_message,
344                }),
345                _ => Err(PlatformApiError::ApiError {
346                    status: status_code,
347                    message: error_message,
348                }),
349            }
350        }
351    }
352
353    // =========================================================================
354    // User API methods
355    // =========================================================================
356
357    /// Get the current authenticated user's profile
358    ///
359    /// Endpoint: GET /api/users/me
360    pub async fn get_current_user(&self) -> Result<UserProfile> {
361        self.get("/api/users/me").await
362    }
363
364    // =========================================================================
365    // Organization API methods
366    // =========================================================================
367
368    /// List organizations the authenticated user belongs to
369    ///
370    /// Endpoint: GET /api/organizations/attended-by-user
371    pub async fn list_organizations(&self) -> Result<Vec<Organization>> {
372        let response: GenericResponse<Vec<Organization>> =
373            self.get("/api/organizations/attended-by-user").await?;
374        Ok(response.data)
375    }
376
377    /// Get an organization by ID
378    ///
379    /// Endpoint: GET /api/organizations/:id
380    pub async fn get_organization(&self, id: &str) -> Result<Organization> {
381        let response: GenericResponse<Organization> =
382            self.get(&format!("/api/organizations/{}", id)).await?;
383        Ok(response.data)
384    }
385
386    // =========================================================================
387    // Project API methods
388    // =========================================================================
389
390    /// List projects in an organization
391    ///
392    /// Endpoint: GET /api/projects/organization/:organizationId
393    pub async fn list_projects(&self, org_id: &str) -> Result<Vec<Project>> {
394        let response: GenericResponse<Vec<Project>> = self
395            .get(&format!("/api/projects/organization/{}", org_id))
396            .await?;
397        Ok(response.data)
398    }
399
400    /// Get a project by ID
401    ///
402    /// Endpoint: GET /api/projects/:id
403    pub async fn get_project(&self, id: &str) -> Result<Project> {
404        let response: GenericResponse<Project> = self.get(&format!("/api/projects/{}", id)).await?;
405        Ok(response.data)
406    }
407
408    /// Create a new project in an organization
409    ///
410    /// Endpoint: POST /api/projects
411    ///
412    /// Note: This first fetches the current user to get the creator_id.
413    pub async fn create_project(
414        &self,
415        org_id: &str,
416        name: &str,
417        description: &str,
418    ) -> Result<Project> {
419        // Get current user to use as creator
420        let user = self.get_current_user().await?;
421
422        let request = serde_json::json!({
423            "creatorId": user.id,
424            "organizationId": org_id,
425            "name": name,
426            "description": description,
427            "context": ""
428        });
429
430        let response: GenericResponse<Project> = self.post("/api/projects", &request).await?;
431        Ok(response.data)
432    }
433
434    // =========================================================================
435    // Repository API methods
436    // =========================================================================
437
438    /// List repositories connected to a project
439    ///
440    /// Returns all GitHub/GitLab repositories that have been connected to the project.
441    /// Use this to get repository info needed for deployment configuration.
442    ///
443    /// Endpoint: GET /api/github/projects/:projectId/repositories
444    pub async fn list_project_repositories(
445        &self,
446        project_id: &str,
447    ) -> Result<ProjectRepositoriesResponse> {
448        let response: GenericResponse<ProjectRepositoriesResponse> = self
449            .get(&format!("/api/github/projects/{}/repositories", project_id))
450            .await?;
451        Ok(response.data)
452    }
453
454    // =========================================================================
455    // GitHub Integration API methods
456    // =========================================================================
457
458    /// List GitHub App installations for the organization
459    ///
460    /// Returns all GitHub App installations accessible to the authenticated user's organization.
461    /// Use this to find which GitHub accounts are connected.
462    ///
463    /// Endpoint: GET /api/github/installations
464    pub async fn list_github_installations(&self) -> Result<GitHubInstallationsResponse> {
465        // API returns { installations: [...] } directly (no GenericResponse wrapper)
466        self.get("/api/github/installations").await
467    }
468
469    /// Get the URL to install the GitHub App
470    ///
471    /// Returns the URL users should visit to install the Syncable GitHub App.
472    /// Use this when no installations are found.
473    ///
474    /// Endpoint: GET /api/github/installation/url
475    pub async fn get_github_installation_url(&self) -> Result<GitHubInstallationUrlResponse> {
476        self.get("/api/github/installation/url").await
477    }
478
479    /// List repositories available for connection
480    ///
481    /// Returns repositories accessible through GitHub App installations,
482    /// including which ones are already connected to the project.
483    ///
484    /// Endpoint: GET /api/github/repositories/available
485    pub async fn list_available_repositories(
486        &self,
487        project_id: Option<&str>,
488        search: Option<&str>,
489        page: Option<i32>,
490    ) -> Result<AvailableRepositoriesResponse> {
491        let mut path = "/api/github/repositories/available".to_string();
492        let mut params = vec![];
493
494        if let Some(pid) = project_id {
495            params.push(format!("projectId={}", pid));
496        }
497        if let Some(s) = search {
498            params.push(format!("search={}", urlencoding::encode(s)));
499        }
500        if let Some(p) = page {
501            params.push(format!("page={}", p));
502        }
503
504        if !params.is_empty() {
505            path = format!("{}?{}", path, params.join("&"));
506        }
507
508        let response: GenericResponse<AvailableRepositoriesResponse> = self.get(&path).await?;
509        Ok(response.data)
510    }
511
512    /// Connect a repository to a project
513    ///
514    /// Connects a GitHub repository to a project, allowing deployments from that repo.
515    ///
516    /// Endpoint: POST /api/github/projects/repositories/connect
517    pub async fn connect_repository(
518        &self,
519        request: &ConnectRepositoryRequest,
520    ) -> Result<ConnectRepositoryResponse> {
521        let response: GenericResponse<ConnectRepositoryResponse> = self
522            .post("/api/github/projects/repositories/connect", request)
523            .await?;
524        Ok(response.data)
525    }
526
527    /// Initialize GitOps repository for a project
528    ///
529    /// Ensures a GitOps infrastructure repository exists for the project.
530    /// If it doesn't exist, automatically creates it using the GitHub App installation.
531    ///
532    /// Endpoint: POST /api/projects/:projectId/gitops/initialize
533    pub async fn initialize_gitops(
534        &self,
535        project_id: &str,
536        installation_id: Option<i64>,
537    ) -> Result<InitializeGitOpsResponse> {
538        let request = InitializeGitOpsRequest { installation_id };
539        let response: GenericResponse<InitializeGitOpsResponse> = self
540            .post(
541                &format!("/api/projects/{}/gitops/initialize", project_id),
542                &request,
543            )
544            .await?;
545        Ok(response.data)
546    }
547
548    // =========================================================================
549    // Environment API methods
550    // =========================================================================
551
552    /// List environments for a project
553    ///
554    /// Returns all environments (deployment targets) defined for the project.
555    ///
556    /// Endpoint: GET /api/projects/:projectId/environments
557    pub async fn list_environments(&self, project_id: &str) -> Result<Vec<Environment>> {
558        let response: GenericResponse<Vec<Environment>> = self
559            .get(&format!("/api/projects/{}/environments", project_id))
560            .await?;
561        Ok(response.data)
562    }
563
564    /// Create a new environment for a project
565    ///
566    /// Creates an environment with the specified type (cluster or cloud).
567    /// For cluster environments, a cluster_id is required.
568    ///
569    /// Endpoint: POST /api/environments
570    ///
571    /// Note: environment_type should be "cluster" (for K8s) or "cloud" (for Cloud Runner)
572    pub async fn create_environment(
573        &self,
574        project_id: &str,
575        name: &str,
576        environment_type: &str,
577        cluster_id: Option<&str>,
578        provider_regions: Option<&std::collections::HashMap<String, String>>,
579    ) -> Result<Environment> {
580        let mut request = serde_json::json!({
581            "projectId": project_id,
582            "name": name,
583            "environmentType": environment_type,
584        });
585
586        if let Some(cid) = cluster_id {
587            request["clusterId"] = serde_json::json!(cid);
588        }
589
590        if let Some(regions) = provider_regions {
591            request["providerRegions"] = serde_json::json!(regions);
592        }
593
594        let response: GenericResponse<Environment> =
595            self.post("/api/environments", &request).await?;
596        Ok(response.data)
597    }
598
599    // =========================================================================
600    // Cloud Credentials API methods
601    // =========================================================================
602
603    /// Check if a cloud provider is connected to a project
604    ///
605    /// Returns `Some(status)` if the provider is connected, `None` if not connected.
606    ///
607    /// SECURITY NOTE: This method only returns connection STATUS, never actual credentials.
608    /// The agent should never have access to OAuth tokens, API keys, or other secrets.
609    ///
610    /// Uses: GET /api/cloud-credentials?projectId=xxx (lists all, then filters)
611    pub async fn check_provider_connection(
612        &self,
613        provider: &CloudProvider,
614        project_id: &str,
615    ) -> Result<Option<CloudCredentialStatus>> {
616        // Use the list endpoint (which works) and filter by provider
617        // The single-provider endpoint may not exist on the backend
618        let all_credentials = self.list_cloud_credentials_for_project(project_id).await?;
619        let matching = all_credentials
620            .into_iter()
621            .find(|c| c.provider.eq_ignore_ascii_case(provider.as_str()));
622        Ok(matching)
623    }
624
625    /// List all cloud credentials for a project
626    ///
627    /// Returns all connected cloud providers for the project.
628    ///
629    /// SECURITY NOTE: This method only returns connection STATUS, never actual credentials.
630    ///
631    /// Endpoint: GET /api/cloud-credentials?projectId=xxx
632    pub async fn list_cloud_credentials_for_project(
633        &self,
634        project_id: &str,
635    ) -> Result<Vec<CloudCredentialStatus>> {
636        let response: GenericResponse<Vec<CloudCredentialStatus>> = self
637            .get(&format!("/api/cloud-credentials?projectId={}", project_id))
638            .await?;
639        Ok(response.data)
640    }
641
642    // =========================================================================
643    // Deployment API methods
644    // =========================================================================
645
646    /// List deployment configurations for a project
647    ///
648    /// Returns all deployment configs associated with the project, including
649    /// service name, branch, target type, and auto-deploy settings.
650    ///
651    /// Endpoint: GET /api/projects/:projectId/deployment-configs
652    pub async fn list_deployment_configs(&self, project_id: &str) -> Result<Vec<DeploymentConfig>> {
653        let response: GenericResponse<Vec<DeploymentConfig>> = self
654            .get(&format!("/api/projects/{}/deployment-configs", project_id))
655            .await?;
656        Ok(response.data)
657    }
658
659    /// Create a new deployment configuration
660    ///
661    /// Creates a deployment config for a service. Requires repository integration
662    /// to be set up first (GitHub/GitLab). The project_id should be included in the request body.
663    ///
664    /// Returns the created/updated deployment config. The API also returns a `was_updated`
665    /// flag indicating whether this was an update to an existing config.
666    ///
667    /// Endpoint: POST /api/deployment-configs
668    pub async fn create_deployment_config(
669        &self,
670        request: &CreateDeploymentConfigRequest,
671    ) -> Result<DeploymentConfig> {
672        // Log the full request for debugging
673        if let Ok(json) = serde_json::to_string_pretty(request) {
674            log::debug!("Creating deployment config with request:\n{}", json);
675        }
676
677        let response: GenericResponse<CreateDeploymentConfigResponse> =
678            self.post("/api/deployment-configs", request).await?;
679
680        log::debug!(
681            "Deployment config created: id={}, serviceName={}, wasUpdated={}",
682            response.data.config.id,
683            response.data.config.service_name,
684            response.data.was_updated
685        );
686
687        Ok(response.data.config)
688    }
689
690    /// Update environment variables / secrets on a deployment config
691    ///
692    /// SECURITY NOTE: This sends secret values over HTTPS to the backend.
693    /// The backend stores them encrypted. API responses mask secret values.
694    ///
695    /// Endpoint: PUT /api/deployment-configs/:configId/secrets
696    pub async fn update_deployment_config_secrets(
697        &self,
698        config_id: &str,
699        secrets: &[DeploymentSecretInput],
700    ) -> Result<()> {
701        let body = serde_json::json!({
702            "configId": config_id,
703            "secrets": secrets,
704        });
705        let _response: GenericResponse<serde_json::Value> = self
706            .put(
707                &format!("/api/deployment-configs/{}/secrets", config_id),
708                &body,
709            )
710            .await?;
711        Ok(())
712    }
713
714    /// Trigger a deployment using a deployment config
715    ///
716    /// Starts a new deployment for the specified config. Optionally specify
717    /// a commit SHA to deploy a specific version.
718    ///
719    /// Endpoint: POST /api/deployment-configs/deploy
720    pub async fn trigger_deployment(
721        &self,
722        request: &TriggerDeploymentRequest,
723    ) -> Result<TriggerDeploymentResponse> {
724        log::debug!(
725            "Triggering deployment: POST /api/deployment-configs/deploy with projectId={}, configId={}",
726            request.project_id,
727            request.config_id
728        );
729
730        // API returns { data: TriggerDeploymentResponse }
731        let response: GenericResponse<TriggerDeploymentResponse> =
732            self.post("/api/deployment-configs/deploy", request).await?;
733
734        log::debug!(
735            "Deployment triggered successfully: backstageTaskId={}, status={}",
736            response.data.backstage_task_id,
737            response.data.status
738        );
739
740        Ok(response.data)
741    }
742
743    /// Get deployment task status
744    ///
745    /// Returns the current status of a deployment task, including progress
746    /// percentage, current step, and overall status.
747    ///
748    /// Endpoint: GET /api/deployments/task/:taskId
749    pub async fn get_deployment_status(&self, task_id: &str) -> Result<DeploymentTaskStatus> {
750        self.get(&format!("/api/deployments/task/{}", task_id))
751            .await
752    }
753
754    /// List deployments for a project
755    ///
756    /// Returns a paginated list of deployments for the project, sorted by
757    /// creation time (most recent first).
758    ///
759    /// Endpoint: GET /api/deployments/project/:projectId
760    pub async fn list_deployments(
761        &self,
762        project_id: &str,
763        limit: Option<i32>,
764    ) -> Result<PaginatedDeployments> {
765        let path = match limit {
766            Some(l) => format!("/api/deployments/project/{}?limit={}", project_id, l),
767            None => format!("/api/deployments/project/{}", project_id),
768        };
769        let response: GenericResponse<PaginatedDeployments> = self.get(&path).await?;
770        Ok(response.data)
771    }
772
773    /// Get container logs for a deployed service
774    ///
775    /// Returns recent logs from the service's containers. Supports time filtering
776    /// and line limits for efficient log retrieval.
777    ///
778    /// # Arguments
779    ///
780    /// * `service_id` - The service/deployment ID (from list_deployments)
781    /// * `start` - Optional ISO timestamp to filter logs from
782    /// * `end` - Optional ISO timestamp to filter logs until
783    /// * `limit` - Optional max number of log lines (default: 100)
784    ///
785    /// Endpoint: GET /api/deployments/services/:serviceId/logs
786    pub async fn get_service_logs(
787        &self,
788        service_id: &str,
789        start: Option<&str>,
790        end: Option<&str>,
791        limit: Option<i32>,
792    ) -> Result<GetLogsResponse> {
793        let mut query_params = Vec::new();
794
795        if let Some(s) = start {
796            query_params.push(format!("start={}", s));
797        }
798        if let Some(e) = end {
799            query_params.push(format!("end={}", e));
800        }
801        if let Some(l) = limit {
802            query_params.push(format!("limit={}", l));
803        }
804
805        let path = if query_params.is_empty() {
806            format!("/api/deployments/services/{}/logs", service_id)
807        } else {
808            format!(
809                "/api/deployments/services/{}/logs?{}",
810                service_id,
811                query_params.join("&")
812            )
813        };
814
815        self.get(&path).await
816    }
817
818    // =========================================================================
819    // Cluster API methods
820    // =========================================================================
821
822    /// List all clusters for a project
823    ///
824    /// Returns all K8s clusters available for deployments in this project.
825    ///
826    /// Endpoint: GET /api/clusters/project/:projectId
827    pub async fn list_clusters_for_project(&self, project_id: &str) -> Result<Vec<ClusterEntity>> {
828        let response: GenericResponse<Vec<ClusterEntity>> = self
829            .get(&format!("/api/clusters/project/{}", project_id))
830            .await?;
831        Ok(response.data)
832    }
833
834    /// Get a specific cluster by ID
835    ///
836    /// Returns cluster details or None if not found.
837    ///
838    /// Endpoint: GET /api/clusters/:clusterId
839    pub async fn get_cluster(&self, cluster_id: &str) -> Result<Option<ClusterEntity>> {
840        // API wraps responses in { "data": ... }, so we need GenericResponse
841        let response: Option<GenericResponse<ClusterEntity>> = self
842            .get_optional(&format!("/api/clusters/{}", cluster_id))
843            .await?;
844        Ok(response.map(|r| r.data))
845    }
846
847    // =========================================================================
848    // Artifact Registry API methods
849    // =========================================================================
850
851    /// List all artifact registries for a project
852    ///
853    /// Returns all container registries available for image storage in this project.
854    ///
855    /// Endpoint: GET /api/projects/:projectId/artifact-registries
856    pub async fn list_registries_for_project(
857        &self,
858        project_id: &str,
859    ) -> Result<Vec<ArtifactRegistry>> {
860        let response: GenericResponse<Vec<ArtifactRegistry>> = self
861            .get(&format!("/api/projects/{}/artifact-registries", project_id))
862            .await?;
863        Ok(response.data)
864    }
865
866    /// List only ready artifact registries for a project
867    ///
868    /// Returns registries that are ready to receive image pushes.
869    /// Use this for deployment wizard to show only usable registries.
870    ///
871    /// Endpoint: GET /api/projects/:projectId/artifact-registries/ready
872    pub async fn list_ready_registries_for_project(
873        &self,
874        project_id: &str,
875    ) -> Result<Vec<ArtifactRegistry>> {
876        let response: GenericResponse<Vec<ArtifactRegistry>> = self
877            .get(&format!(
878                "/api/projects/{}/artifact-registries/ready",
879                project_id
880            ))
881            .await?;
882        Ok(response.data)
883    }
884
885    /// Provision a new artifact registry
886    ///
887    /// Starts async provisioning via Backstage scaffolder.
888    /// Returns task ID for polling status.
889    ///
890    /// Endpoint: POST /api/projects/:projectId/artifact-registries
891    pub async fn create_registry(
892        &self,
893        project_id: &str,
894        request: &CreateRegistryRequest,
895    ) -> Result<CreateRegistryResponse> {
896        self.post(
897            &format!("/api/projects/{}/artifact-registries", project_id),
898            request,
899        )
900        .await
901    }
902
903    /// Get registry provisioning task status
904    ///
905    /// Poll this endpoint to check provisioning progress.
906    ///
907    /// Endpoint: GET /api/artifact-registries/task/:taskId
908    pub async fn get_registry_task_status(&self, task_id: &str) -> Result<RegistryTaskStatus> {
909        self.get(&format!("/api/artifact-registries/task/{}", task_id))
910            .await
911    }
912
913    // =========================================================================
914    // Hetzner Availability API methods (Dynamic Resource Fetching)
915    // =========================================================================
916
917    /// Get Hetzner options (locations and server types) with real-time data
918    ///
919    /// Uses the /api/v1/cloud-runner/hetzner/options endpoint which returns
920    /// both locations and server types in one call. This is the same endpoint
921    /// used by the frontend for Hetzner infrastructure selection.
922    ///
923    /// Endpoint: GET /api/v1/cloud-runner/hetzner/options?projectId=:projectId
924    pub async fn get_hetzner_options(
925        &self,
926        project_id: &str,
927    ) -> Result<super::types::HetznerOptionsData> {
928        let response: super::types::HetznerOptionsResponse = self
929            .get(&format!(
930                "/api/v1/cloud-runner/hetzner/options?projectId={}",
931                urlencoding::encode(project_id)
932            ))
933            .await?;
934        Ok(response.data)
935    }
936
937    /// Get Hetzner locations with real-time availability information
938    ///
939    /// Returns all Hetzner locations with the server types currently available
940    /// at each location. Uses the customer's Hetzner API token stored in their
941    /// cloud credentials to query the Hetzner API.
942    ///
943    /// This enables dynamic resource selection instead of relying on hardcoded values.
944    ///
945    /// Endpoint: GET /api/deployments/availability/locations?projectId=:projectId
946    pub async fn get_hetzner_locations(
947        &self,
948        project_id: &str,
949    ) -> Result<Vec<super::types::LocationWithAvailability>> {
950        let response: super::types::LocationsAvailabilityResponse = self
951            .get(&format!(
952                "/api/deployments/availability/locations?projectId={}",
953                urlencoding::encode(project_id)
954            ))
955            .await?;
956        Ok(response.data)
957    }
958
959    /// Get Hetzner server types with pricing and availability
960    ///
961    /// Returns all non-deprecated Hetzner server types sorted by monthly price,
962    /// with availability information showing which locations have capacity.
963    ///
964    /// Use this to dynamically populate server type selection UI and enable
965    /// smart resource recommendations based on real pricing data.
966    ///
967    /// Endpoint: GET /api/deployments/availability/server-types?projectId=:projectId&preferredLocation=:location
968    pub async fn get_hetzner_server_types(
969        &self,
970        project_id: &str,
971        preferred_location: Option<&str>,
972    ) -> Result<Vec<super::types::ServerTypeSummary>> {
973        let mut path = format!(
974            "/api/deployments/availability/server-types?projectId={}",
975            urlencoding::encode(project_id)
976        );
977        if let Some(location) = preferred_location {
978            path.push_str(&format!(
979                "&preferredLocation={}",
980                urlencoding::encode(location)
981            ));
982        }
983        let response: super::types::ServerTypesResponse = self.get(&path).await?;
984        Ok(response.data)
985    }
986
987    /// Check if a specific server type is available at a location
988    ///
989    /// Returns availability status with:
990    /// - Whether the server type is available
991    /// - Reason if unavailable (capacity vs unsupported)
992    /// - Alternative locations where it IS available
993    ///
994    /// Use this before deployment to detect capacity issues early and suggest alternatives.
995    ///
996    /// Endpoint: GET /api/deployments/availability/check?projectId=:projectId&location=:location&serverType=:serverType
997    pub async fn check_hetzner_availability(
998        &self,
999        project_id: &str,
1000        location: &str,
1001        server_type: &str,
1002    ) -> Result<super::types::AvailabilityCheckResult> {
1003        self.get(&format!(
1004            "/api/deployments/availability/check?projectId={}&location={}&serverType={}",
1005            urlencoding::encode(project_id),
1006            urlencoding::encode(location),
1007            urlencoding::encode(server_type)
1008        ))
1009        .await
1010    }
1011
1012    // =========================================================================
1013    // Cloud Runner Network API methods
1014    // =========================================================================
1015
1016    /// List all cloud runner networks for a project
1017    ///
1018    /// Returns VPCs, subnets, Azure Container App Environments, GCP VPC Connectors, etc.
1019    /// Use this to discover private networking infrastructure provisioned for the project.
1020    ///
1021    /// Endpoint: GET /api/v1/cloud-runner/projects/:projectId/networks
1022    pub async fn list_project_networks(&self, project_id: &str) -> Result<Vec<CloudRunnerNetwork>> {
1023        let response: GenericResponse<Vec<CloudRunnerNetwork>> = self
1024            .get(&format!(
1025                "/api/v1/cloud-runner/projects/{}/networks",
1026                project_id
1027            ))
1028            .await?;
1029        Ok(response.data)
1030    }
1031
1032    // =========================================================================
1033    // Health Check API methods
1034    // =========================================================================
1035
1036    /// Check if the API is reachable (quick health check)
1037    ///
1038    /// Uses a shorter timeout (5s) for quick connectivity verification.
1039    /// This method does NOT require authentication.
1040    ///
1041    /// Returns `Ok(())` if API is reachable, `Err(ConnectionFailed)` otherwise.
1042    pub async fn check_connection(&self) -> Result<()> {
1043        // Use a shorter timeout for health checks
1044        let health_client = Client::builder()
1045            .timeout(Duration::from_secs(5))
1046            .user_agent(USER_AGENT)
1047            .build()
1048            .map_err(PlatformApiError::HttpError)?;
1049
1050        let url = format!("{}/health", self.api_url);
1051
1052        match health_client.get(&url).send().await {
1053            Ok(response) => {
1054                if response.status().is_success() {
1055                    Ok(())
1056                } else {
1057                    Err(PlatformApiError::ConnectionFailed)
1058                }
1059            }
1060            Err(_) => Err(PlatformApiError::ConnectionFailed),
1061        }
1062    }
1063}
1064
1065/// Get the API URL based on environment
1066fn get_api_url() -> &'static str {
1067    if std::env::var("SYNCABLE_ENV").as_deref() == Ok("development") {
1068        SYNCABLE_API_URL_DEV
1069    } else {
1070        SYNCABLE_API_URL_PROD
1071    }
1072}
1073
1074#[cfg(test)]
1075mod tests {
1076    use super::*;
1077
1078    #[test]
1079    fn test_client_construction() {
1080        let client = PlatformApiClient::with_url("https://example.com").unwrap();
1081        assert_eq!(client.api_url(), "https://example.com");
1082    }
1083
1084    #[test]
1085    fn test_url_building() {
1086        let client = PlatformApiClient::with_url("https://api.example.com").unwrap();
1087
1088        // Verify the base URL is stored correctly
1089        assert_eq!(client.api_url(), "https://api.example.com");
1090
1091        // Test path concatenation logic (implicitly tested through api_url)
1092        let expected_path = format!("{}/api/organizations/123", client.api_url());
1093        assert_eq!(
1094            expected_path,
1095            "https://api.example.com/api/organizations/123"
1096        );
1097    }
1098
1099    #[test]
1100    fn test_error_type_creation() {
1101        // Test that error types can be created correctly
1102        let unauthorized = PlatformApiError::Unauthorized;
1103        assert!(unauthorized.to_string().contains("Not authenticated"));
1104
1105        let not_found = PlatformApiError::NotFound("Resource not found".to_string());
1106        assert!(not_found.to_string().contains("Not found"));
1107
1108        let api_error = PlatformApiError::ApiError {
1109            status: 400,
1110            message: "Bad request".to_string(),
1111        };
1112        assert!(api_error.to_string().contains("400"));
1113        assert!(api_error.to_string().contains("Bad request"));
1114
1115        let permission_denied = PlatformApiError::PermissionDenied("Access denied".to_string());
1116        assert!(permission_denied.to_string().contains("Permission denied"));
1117
1118        let rate_limited = PlatformApiError::RateLimited;
1119        assert!(rate_limited.to_string().contains("Rate limit"));
1120
1121        let server_error = PlatformApiError::ServerError {
1122            status: 500,
1123            message: "Internal server error".to_string(),
1124        };
1125        assert!(server_error.to_string().contains("500"));
1126    }
1127
1128    #[test]
1129    fn test_api_url_constants() {
1130        // Test that our URL constants are valid
1131        assert!(SYNCABLE_API_URL_PROD.starts_with("https://"));
1132        assert!(SYNCABLE_API_URL_DEV.starts_with("http://"));
1133    }
1134
1135    #[test]
1136    fn test_user_agent() {
1137        // Verify user agent contains version
1138        assert!(USER_AGENT.starts_with("syncable-cli/"));
1139    }
1140
1141    #[test]
1142    fn test_parse_error_creation() {
1143        let error = PlatformApiError::ParseError("invalid json".to_string());
1144        assert!(error.to_string().contains("parse"));
1145        assert!(error.to_string().contains("invalid json"));
1146    }
1147
1148    #[test]
1149    fn test_http_error_conversion() {
1150        // Test that reqwest errors can be converted
1151        // This is a compile-time check via the From trait
1152        let _: fn(reqwest::Error) -> PlatformApiError = PlatformApiError::from;
1153    }
1154
1155    #[test]
1156    fn test_provider_connection_path() {
1157        // Test that the API path is built correctly
1158        let provider = CloudProvider::Gcp;
1159        let project_id = "proj-123";
1160        let expected_path = format!(
1161            "/api/cloud-credentials/provider/{}?projectId={}",
1162            provider.as_str(),
1163            project_id
1164        );
1165        assert_eq!(
1166            expected_path,
1167            "/api/cloud-credentials/provider/gcp?projectId=proj-123"
1168        );
1169    }
1170
1171    #[test]
1172    fn test_service_logs_path_no_params() {
1173        // Test logs path without query params
1174        let service_id = "svc-123";
1175        let path = format!("/api/deployments/services/{}/logs", service_id);
1176        assert_eq!(path, "/api/deployments/services/svc-123/logs");
1177    }
1178
1179    #[test]
1180    fn test_service_logs_path_with_params() {
1181        // Test logs path with query params
1182        let service_id = "svc-123";
1183        let mut query_params = Vec::new();
1184        query_params.push("start=2024-01-01T00:00:00Z".to_string());
1185        query_params.push("limit=50".to_string());
1186        let path = format!(
1187            "/api/deployments/services/{}/logs?{}",
1188            service_id,
1189            query_params.join("&")
1190        );
1191        assert_eq!(
1192            path,
1193            "/api/deployments/services/svc-123/logs?start=2024-01-01T00:00:00Z&limit=50"
1194        );
1195    }
1196
1197    #[test]
1198    fn test_list_environments_path() {
1199        // Test that the API path is built correctly
1200        let project_id = "proj-123";
1201        let path = format!("/api/projects/{}/environments", project_id);
1202        assert_eq!(path, "/api/projects/proj-123/environments");
1203    }
1204
1205    #[test]
1206    fn test_create_environment_request() {
1207        // Test that the request JSON is built correctly
1208        let project_id = "proj-123";
1209        let name = "production";
1210        let environment_type = "cluster";
1211        let cluster_id = Some("cluster-456");
1212
1213        let mut request = serde_json::json!({
1214            "projectId": project_id,
1215            "name": name,
1216            "environmentType": environment_type,
1217        });
1218
1219        if let Some(cid) = cluster_id {
1220            request["clusterId"] = serde_json::json!(cid);
1221        }
1222
1223        let json_str = request.to_string();
1224        assert!(json_str.contains("\"projectId\":\"proj-123\""));
1225        assert!(json_str.contains("\"name\":\"production\""));
1226        assert!(json_str.contains("\"environmentType\":\"cluster\""));
1227        assert!(json_str.contains("\"clusterId\":\"cluster-456\""));
1228    }
1229
1230    #[test]
1231    fn test_create_environment_request_cloud() {
1232        // Test request without cluster_id (cloud runner)
1233        let project_id = "proj-123";
1234        let name = "staging";
1235        let environment_type = "cloud";
1236        let cluster_id: Option<&str> = None;
1237        let provider_regions: Option<&std::collections::HashMap<String, String>> = None;
1238
1239        let mut request = serde_json::json!({
1240            "projectId": project_id,
1241            "name": name,
1242            "environmentType": environment_type,
1243        });
1244
1245        if let Some(cid) = cluster_id {
1246            request["clusterId"] = serde_json::json!(cid);
1247        }
1248
1249        if let Some(regions) = provider_regions {
1250            request["providerRegions"] = serde_json::json!(regions);
1251        }
1252
1253        let json_str = request.to_string();
1254        assert!(json_str.contains("\"environmentType\":\"cloud\""));
1255        assert!(!json_str.contains("clusterId"));
1256        assert!(!json_str.contains("providerRegions"));
1257    }
1258
1259    #[test]
1260    fn test_create_environment_request_with_provider_regions() {
1261        let project_id = "proj-123";
1262        let name = "staging";
1263        let environment_type = "cloud";
1264
1265        let mut provider_regions = std::collections::HashMap::new();
1266        provider_regions.insert("gcp".to_string(), "us-central1".to_string());
1267        provider_regions.insert("azure".to_string(), "eastus".to_string());
1268
1269        let mut request = serde_json::json!({
1270            "projectId": project_id,
1271            "name": name,
1272            "environmentType": environment_type,
1273        });
1274
1275        request["providerRegions"] = serde_json::json!(&provider_regions);
1276
1277        let json_str = request.to_string();
1278        assert!(json_str.contains("\"providerRegions\""));
1279        assert!(json_str.contains("\"gcp\":\"us-central1\""));
1280        assert!(json_str.contains("\"azure\":\"eastus\""));
1281    }
1282}