1use 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
25const SYNCABLE_API_URL_PROD: &str = "https://syncable.dev";
27const SYNCABLE_API_URL_DEV: &str = "http://localhost:4000";
29
30const USER_AGENT: &str = concat!("syncable-cli/", env!("CARGO_PKG_VERSION"));
32
33const MAX_RETRIES: u32 = 3;
35const INITIAL_BACKOFF_MS: u64 = 500;
37const MAX_BACKOFF_MS: u64 = 5000;
39
40fn is_retryable_error(error: &PlatformApiError) -> bool {
42 matches!(
43 error,
44 PlatformApiError::HttpError(_) | PlatformApiError::RateLimited | PlatformApiError::ServerError { .. } | PlatformApiError::ConnectionFailed )
49}
50
51pub struct PlatformApiClient {
53 http_client: Client,
55 api_url: String,
57}
58
59impl PlatformApiClient {
60 pub fn new() -> Result<Self> {
64 let api_url = get_api_url();
65 Self::with_url(api_url)
66 }
67
68 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 pub fn api_url(&self) -> &str {
84 &self.api_url
85 }
86
87 fn get_auth_token() -> Result<String> {
89 credentials::get_access_token().ok_or(PlatformApiError::Unauthorized)
90 }
91
92 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 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 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 return self.handle_response(response).await;
247 }
248 Err(e) => {
249 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 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 return self.handle_response(response).await;
294 }
295 Err(e) => {
296 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 async fn handle_response<T: DeserializeOwned>(&self, response: reqwest::Response) -> Result<T> {
320 let status = response.status();
321
322 if status.is_success() {
323 response
325 .json::<T>()
326 .await
327 .map_err(|e| PlatformApiError::ParseError(e.to_string()))
328 } else {
329 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 pub async fn get_current_user(&self) -> Result<UserProfile> {
361 self.get("/api/users/me").await
362 }
363
364 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 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 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 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 pub async fn create_project(
414 &self,
415 org_id: &str,
416 name: &str,
417 description: &str,
418 ) -> Result<Project> {
419 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 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 pub async fn list_github_installations(&self) -> Result<GitHubInstallationsResponse> {
465 self.get("/api/github/installations").await
467 }
468
469 pub async fn get_github_installation_url(&self) -> Result<GitHubInstallationUrlResponse> {
476 self.get("/api/github/installation/url").await
477 }
478
479 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 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 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 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 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 pub async fn check_provider_connection(
612 &self,
613 provider: &CloudProvider,
614 project_id: &str,
615 ) -> Result<Option<CloudCredentialStatus>> {
616 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 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 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 pub async fn create_deployment_config(
669 &self,
670 request: &CreateDeploymentConfigRequest,
671 ) -> Result<DeploymentConfig> {
672 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 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 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 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 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 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 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 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 pub async fn get_cluster(&self, cluster_id: &str) -> Result<Option<ClusterEntity>> {
840 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 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 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 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 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 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 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 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 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 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 pub async fn check_connection(&self) -> Result<()> {
1043 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
1065fn 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 assert_eq!(client.api_url(), "https://api.example.com");
1090
1091 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 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 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 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 let _: fn(reqwest::Error) -> PlatformApiError = PlatformApiError::from;
1153 }
1154
1155 #[test]
1156 fn test_provider_connection_path() {
1157 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 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 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 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 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 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}