1use 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
24const SYNCABLE_API_URL_PROD: &str = "https://syncable.dev";
26const SYNCABLE_API_URL_DEV: &str = "http://localhost:4000";
28
29const USER_AGENT: &str = concat!("syncable-cli/", env!("CARGO_PKG_VERSION"));
31
32const MAX_RETRIES: u32 = 3;
34const INITIAL_BACKOFF_MS: u64 = 500;
36const MAX_BACKOFF_MS: u64 = 5000;
38
39fn is_retryable_error(error: &PlatformApiError) -> bool {
41 matches!(
42 error,
43 PlatformApiError::HttpError(_) | PlatformApiError::RateLimited | PlatformApiError::ServerError { .. } | PlatformApiError::ConnectionFailed )
48}
49
50pub struct PlatformApiClient {
52 http_client: Client,
54 api_url: String,
56}
57
58impl PlatformApiClient {
59 pub fn new() -> Result<Self> {
63 let api_url = get_api_url();
64 Self::with_url(api_url)
65 }
66
67 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 pub fn api_url(&self) -> &str {
83 &self.api_url
84 }
85
86 fn get_auth_token() -> Result<String> {
88 credentials::get_access_token().ok_or(PlatformApiError::Unauthorized)
89 }
90
91 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 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 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 return self.handle_response(response).await;
258 }
259 Err(e) => {
260 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 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 response
292 .json::<T>()
293 .await
294 .map_err(|e| PlatformApiError::ParseError(e.to_string()))
295 } else {
296 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 pub async fn get_current_user(&self) -> Result<UserProfile> {
328 self.get("/api/users/me").await
329 }
330
331 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 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 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 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 pub async fn create_project(
382 &self,
383 org_id: &str,
384 name: &str,
385 description: &str,
386 ) -> Result<Project> {
387 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 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 pub async fn list_github_installations(&self) -> Result<GitHubInstallationsResponse> {
436 self.get("/api/github/installations").await
438 }
439
440 pub async fn get_github_installation_url(&self) -> Result<GitHubInstallationUrlResponse> {
447 self.get("/api/github/installation/url").await
448 }
449
450 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 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 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 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 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 pub async fn check_provider_connection(
578 &self,
579 provider: &CloudProvider,
580 project_id: &str,
581 ) -> Result<Option<CloudCredentialStatus>> {
582 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 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 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 pub async fn create_deployment_config(
635 &self,
636 request: &CreateDeploymentConfigRequest,
637 ) -> Result<DeploymentConfig> {
638 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 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 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 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 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 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 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 pub async fn get_cluster(&self, cluster_id: &str) -> Result<Option<ClusterEntity>> {
781 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 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 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 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 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 pub async fn check_connection(&self) -> Result<()> {
865 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
887fn 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 assert_eq!(client.api_url(), "https://api.example.com");
912
913 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 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 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 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 let _: fn(reqwest::Error) -> PlatformApiError = PlatformApiError::from;
973 }
974
975 #[test]
976 fn test_provider_connection_path() {
977 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 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 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 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 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 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}