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