1use crate::runbeam_api::types::{
2 ApiError, AuthorizeResponse, RunbeamError, StoreConfigRequest, StoreConfigResponse,
3};
4use serde::Serialize;
5
6#[derive(Debug, Clone)]
11pub struct RunbeamClient {
12 base_url: String,
14 client: reqwest::Client,
16}
17
18#[derive(Debug, Serialize)]
20struct AuthorizeRequest {
21 token: String,
23 gateway_code: String,
25 #[serde(skip_serializing_if = "Option::is_none")]
27 machine_public_key: Option<String>,
28 #[serde(skip_serializing_if = "Option::is_none")]
30 metadata: Option<Vec<String>>,
31}
32
33impl RunbeamClient {
34 pub fn new(base_url: impl Into<String>) -> Self {
48 let base_url = base_url.into();
49 tracing::debug!("Creating RunbeamClient with base URL: {}", base_url);
50
51 let accept_invalid_certs = std::env::var("RUNBEAM_ACCEPT_INVALID_CERTS")
53 .ok()
54 .and_then(|v| v.parse::<bool>().ok())
55 .unwrap_or(false);
56
57 let client = if accept_invalid_certs {
58 tracing::warn!("⚠️ Accepting invalid SSL certificates (RUNBEAM_ACCEPT_INVALID_CERTS=true). This should only be used in development!");
59 reqwest::Client::builder()
60 .danger_accept_invalid_certs(true)
61 .build()
62 .expect("Failed to create HTTP client")
63 } else {
64 reqwest::Client::new()
65 };
66
67 Self {
68 base_url,
69 client,
70 }
71 }
72
73 pub async fn authorize_gateway(
130 &self,
131 user_token: impl Into<String>,
132 gateway_code: impl Into<String>,
133 machine_public_key: Option<String>,
134 metadata: Option<Vec<String>>,
135 ) -> Result<AuthorizeResponse, RunbeamError> {
136 let user_token = user_token.into();
137 let gateway_code = gateway_code.into();
138
139 tracing::info!(
140 "Authorizing gateway with Runbeam Cloud: gateway_code={}",
141 gateway_code
142 );
143
144 let url = format!("{}/harmony/authorize", self.base_url);
146
147 let payload = AuthorizeRequest {
149 token: user_token.clone(),
150 gateway_code: gateway_code.clone(),
151 machine_public_key,
152 metadata,
153 };
154
155 tracing::debug!("Sending authorization request to: {}", url);
156
157 let response = self
159 .client
160 .post(&url)
161 .header("Authorization", format!("Bearer {}", user_token))
162 .header("Content-Type", "application/json")
163 .json(&payload)
164 .send()
165 .await
166 .map_err(|e| {
167 tracing::error!("Failed to send authorization request: {}", e);
168 ApiError::from(e)
169 })?;
170
171 let status = response.status();
172 tracing::debug!("Received response with status: {}", status);
173
174 if !status.is_success() {
176 let error_body = response
177 .text()
178 .await
179 .unwrap_or_else(|_| "Unknown error".to_string());
180
181 tracing::error!(
182 "Authorization failed: HTTP {} - {}",
183 status.as_u16(),
184 error_body
185 );
186
187 return Err(RunbeamError::Api(ApiError::Http {
188 status: status.as_u16(),
189 message: error_body,
190 }));
191 }
192
193 let auth_response: AuthorizeResponse = response.json().await.map_err(|e| {
195 tracing::error!("Failed to parse authorization response: {}", e);
196 ApiError::Parse(format!("Failed to parse response JSON: {}", e))
197 })?;
198
199 tracing::info!(
200 "Gateway authorized successfully: gateway_id={}, expires_at={}",
201 auth_response.gateway.id,
202 auth_response.expires_at
203 );
204
205 tracing::debug!(
206 "Machine token length: {}",
207 auth_response.machine_token.len()
208 );
209 tracing::debug!("Gateway abilities: {:?}", auth_response.abilities);
210
211 Ok(auth_response)
212 }
213
214 pub fn base_url(&self) -> &str {
216 &self.base_url
217 }
218
219 pub async fn list_changes(
246 &self,
247 token: impl Into<String>,
248 ) -> Result<
249 crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Change>,
250 RunbeamError,
251 > {
252 let url = format!("{}/harmony/changes", self.base_url);
253
254 tracing::debug!("Listing all changes from: {}", url);
255
256 let response = self
257 .client
258 .get(&url)
259 .header("Authorization", format!("Bearer {}", token.into()))
260 .send()
261 .await
262 .map_err(ApiError::from)?;
263
264 if !response.status().is_success() {
265 let status = response.status();
266 let error_body = response
267 .text()
268 .await
269 .unwrap_or_else(|_| "Unknown error".to_string());
270 tracing::error!("Failed to list changes: HTTP {} - {}", status, error_body);
271 return Err(RunbeamError::Api(ApiError::Http {
272 status: status.as_u16(),
273 message: error_body,
274 }));
275 }
276
277 response.json().await.map_err(|e| {
278 tracing::error!("Failed to parse changes response: {}", e);
279 RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
280 })
281 }
282
283 pub async fn list_changes_for_gateway(
314 &self,
315 token: impl Into<String>,
316 gateway_id: impl Into<String>,
317 ) -> Result<
318 crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Change>,
319 RunbeamError,
320 > {
321 let gateway_id = gateway_id.into();
322 let url = format!("{}/harmony/changes/{}", self.base_url, gateway_id);
323
324 tracing::debug!("Listing changes for gateway {} from: {}", gateway_id, url);
325
326 let response = self
327 .client
328 .get(&url)
329 .header("Authorization", format!("Bearer {}", token.into()))
330 .send()
331 .await
332 .map_err(ApiError::from)?;
333
334 if !response.status().is_success() {
335 let status = response.status();
336 let error_body = response
337 .text()
338 .await
339 .unwrap_or_else(|_| "Unknown error".to_string());
340 tracing::error!(
341 "Failed to list changes for gateway {}: HTTP {} - {}",
342 gateway_id,
343 status,
344 error_body
345 );
346 return Err(RunbeamError::Api(ApiError::Http {
347 status: status.as_u16(),
348 message: error_body,
349 }));
350 }
351
352 let response_text = response.text().await.map_err(|e| {
353 tracing::error!("Failed to read response body: {}", e);
354 RunbeamError::Api(ApiError::Parse(format!("Failed to read response: {}", e)))
355 })?;
356
357 serde_json::from_str(&response_text).map_err(|e| {
358 tracing::error!(
359 "Failed to parse changes response: {} - Response body: {}",
360 e,
361 response_text
362 );
363 RunbeamError::Api(ApiError::Parse(format!(
364 "Failed to parse response: {} - Body: {}",
365 e, response_text
366 )))
367 })
368 }
369
370 pub async fn get_change(
400 &self,
401 token: impl Into<String>,
402 change_id: impl Into<String>,
403 ) -> Result<
404 crate::runbeam_api::resources::ResourceResponse<crate::runbeam_api::resources::Change>,
405 RunbeamError,
406 > {
407 let change_id = change_id.into();
408 let url = format!("{}/harmony/change/{}", self.base_url, change_id);
409
410 tracing::debug!("Getting change {} from: {}", change_id, url);
411
412 let response = self
413 .client
414 .get(&url)
415 .header("Authorization", format!("Bearer {}", token.into()))
416 .send()
417 .await
418 .map_err(ApiError::from)?;
419
420 if !response.status().is_success() {
421 let status = response.status();
422 let error_body = response
423 .text()
424 .await
425 .unwrap_or_else(|_| "Unknown error".to_string());
426 tracing::error!("Failed to get change: HTTP {} - {}", status, error_body);
427 return Err(RunbeamError::Api(ApiError::Http {
428 status: status.as_u16(),
429 message: error_body,
430 }));
431 }
432
433 response.json().await.map_err(|e| {
434 tracing::error!("Failed to parse change response: {}", e);
435 RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
436 })
437 }
438
439 pub async fn list_gateways(
452 &self,
453 token: impl Into<String>,
454 ) -> Result<
455 crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Gateway>,
456 RunbeamError,
457 > {
458 let url = format!("{}/gateways", self.base_url);
459
460 let response = self
461 .client
462 .get(&url)
463 .header("Authorization", format!("Bearer {}", token.into()))
464 .send()
465 .await
466 .map_err(ApiError::from)?;
467
468 if !response.status().is_success() {
469 let status = response.status();
470 let error_body = response
471 .text()
472 .await
473 .unwrap_or_else(|_| "Unknown error".to_string());
474 return Err(RunbeamError::Api(ApiError::Http {
475 status: status.as_u16(),
476 message: error_body,
477 }));
478 }
479
480 response.json().await.map_err(|e| {
481 RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
482 })
483 }
484
485 pub async fn get_gateway(
497 &self,
498 token: impl Into<String>,
499 gateway_id: impl Into<String>,
500 ) -> Result<
501 crate::runbeam_api::resources::ResourceResponse<crate::runbeam_api::resources::Gateway>,
502 RunbeamError,
503 > {
504 let url = format!("{}/gateways/{}", self.base_url, gateway_id.into());
505
506 let response = self
507 .client
508 .get(&url)
509 .header("Authorization", format!("Bearer {}", token.into()))
510 .send()
511 .await
512 .map_err(ApiError::from)?;
513
514 if !response.status().is_success() {
515 let status = response.status();
516 let error_body = response
517 .text()
518 .await
519 .unwrap_or_else(|_| "Unknown error".to_string());
520 return Err(RunbeamError::Api(ApiError::Http {
521 status: status.as_u16(),
522 message: error_body,
523 }));
524 }
525
526 response.json().await.map_err(|e| {
527 RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
528 })
529 }
530
531 pub async fn list_services(
544 &self,
545 token: impl Into<String>,
546 ) -> Result<
547 crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Service>,
548 RunbeamError,
549 > {
550 let url = format!("{}/api/services", self.base_url);
551
552 let response = self
553 .client
554 .get(&url)
555 .header("Authorization", format!("Bearer {}", token.into()))
556 .send()
557 .await
558 .map_err(ApiError::from)?;
559
560 if !response.status().is_success() {
561 let status = response.status();
562 let error_body = response
563 .text()
564 .await
565 .unwrap_or_else(|_| "Unknown error".to_string());
566 return Err(RunbeamError::Api(ApiError::Http {
567 status: status.as_u16(),
568 message: error_body,
569 }));
570 }
571
572 response.json().await.map_err(|e| {
573 RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
574 })
575 }
576
577 pub async fn get_service(
589 &self,
590 token: impl Into<String>,
591 service_id: impl Into<String>,
592 ) -> Result<
593 crate::runbeam_api::resources::ResourceResponse<crate::runbeam_api::resources::Service>,
594 RunbeamError,
595 > {
596 let url = format!("{}/api/services/{}", self.base_url, service_id.into());
597
598 let response = self
599 .client
600 .get(&url)
601 .header("Authorization", format!("Bearer {}", token.into()))
602 .send()
603 .await
604 .map_err(ApiError::from)?;
605
606 if !response.status().is_success() {
607 let status = response.status();
608 let error_body = response
609 .text()
610 .await
611 .unwrap_or_else(|_| "Unknown error".to_string());
612 return Err(RunbeamError::Api(ApiError::Http {
613 status: status.as_u16(),
614 message: error_body,
615 }));
616 }
617
618 response.json().await.map_err(|e| {
619 RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
620 })
621 }
622
623 pub async fn list_endpoints(
634 &self,
635 token: impl Into<String>,
636 ) -> Result<
637 crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Endpoint>,
638 RunbeamError,
639 > {
640 let url = format!("{}/api/endpoints", self.base_url);
641
642 let response = self
643 .client
644 .get(&url)
645 .header("Authorization", format!("Bearer {}", token.into()))
646 .send()
647 .await
648 .map_err(ApiError::from)?;
649
650 if !response.status().is_success() {
651 let status = response.status();
652 let error_body = response
653 .text()
654 .await
655 .unwrap_or_else(|_| "Unknown error".to_string());
656 return Err(RunbeamError::Api(ApiError::Http {
657 status: status.as_u16(),
658 message: error_body,
659 }));
660 }
661
662 response.json().await.map_err(|e| {
663 RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
664 })
665 }
666
667 pub async fn list_backends(
678 &self,
679 token: impl Into<String>,
680 ) -> Result<
681 crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Backend>,
682 RunbeamError,
683 > {
684 let url = format!("{}/api/backends", self.base_url);
685
686 let response = self
687 .client
688 .get(&url)
689 .header("Authorization", format!("Bearer {}", token.into()))
690 .send()
691 .await
692 .map_err(ApiError::from)?;
693
694 if !response.status().is_success() {
695 let status = response.status();
696 let error_body = response
697 .text()
698 .await
699 .unwrap_or_else(|_| "Unknown error".to_string());
700 return Err(RunbeamError::Api(ApiError::Http {
701 status: status.as_u16(),
702 message: error_body,
703 }));
704 }
705
706 response.json().await.map_err(|e| {
707 RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
708 })
709 }
710
711 pub async fn list_pipelines(
722 &self,
723 token: impl Into<String>,
724 ) -> Result<
725 crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Pipeline>,
726 RunbeamError,
727 > {
728 let url = format!("{}/api/pipelines", self.base_url);
729
730 let response = self
731 .client
732 .get(&url)
733 .header("Authorization", format!("Bearer {}", token.into()))
734 .send()
735 .await
736 .map_err(ApiError::from)?;
737
738 if !response.status().is_success() {
739 let status = response.status();
740 let error_body = response
741 .text()
742 .await
743 .unwrap_or_else(|_| "Unknown error".to_string());
744 return Err(RunbeamError::Api(ApiError::Http {
745 status: status.as_u16(),
746 message: error_body,
747 }));
748 }
749
750 response.json().await.map_err(|e| {
751 RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
752 })
753 }
754
755 pub async fn get_transform(
790 &self,
791 token: impl Into<String>,
792 transform_id: impl Into<String>,
793 ) -> Result<
794 crate::runbeam_api::resources::ResourceResponse<crate::runbeam_api::resources::Transform>,
795 RunbeamError,
796 > {
797 let transform_id = transform_id.into();
798 let url = format!("{}/api/transforms/{}", self.base_url, transform_id);
799
800 tracing::debug!("Getting transform {} from: {}", transform_id, url);
801
802 let response = self
803 .client
804 .get(&url)
805 .header("Authorization", format!("Bearer {}", token.into()))
806 .send()
807 .await
808 .map_err(ApiError::from)?;
809
810 if !response.status().is_success() {
811 let status = response.status();
812 let error_body = response
813 .text()
814 .await
815 .unwrap_or_else(|_| "Unknown error".to_string());
816 tracing::error!("Failed to get transform: HTTP {} - {}", status, error_body);
817 return Err(RunbeamError::Api(ApiError::Http {
818 status: status.as_u16(),
819 message: error_body,
820 }));
821 }
822
823 response.json().await.map_err(|e| {
824 tracing::error!("Failed to parse transform response: {}", e);
825 RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
826 })
827 }
828
829 pub async fn get_base_url(
857 &self,
858 token: impl Into<String>,
859 ) -> Result<crate::runbeam_api::resources::BaseUrlResponse, RunbeamError> {
860 let token = token.into();
861 let candidates = [
863 format!("{}/api/harmony/base-url", self.base_url),
864 format!("{}/harmony/base-url", self.base_url),
865 ];
866
867 let mut last_err: Option<RunbeamError> = None;
868 for url in candidates {
869 tracing::debug!("Getting base URL from: {}", url);
870 let resp = self
871 .client
872 .get(&url)
873 .header("Authorization", format!("Bearer {}", token))
874 .send()
875 .await;
876
877 let response = match resp {
878 Ok(r) => r,
879 Err(e) => {
880 last_err = Some(ApiError::from(e).into());
881 continue;
882 }
883 };
884
885 if !response.status().is_success() {
886 let status = response.status();
887 let error_body = response
888 .text()
889 .await
890 .unwrap_or_else(|_| "Unknown error".to_string());
891 tracing::warn!(
892 "Base URL discovery attempt failed: HTTP {} - {} (url: {})",
893 status,
894 error_body,
895 url
896 );
897 last_err = Some(RunbeamError::Api(ApiError::Http {
898 status: status.as_u16(),
899 message: error_body,
900 }));
901 continue;
902 }
903
904 let parsed = response.json().await.map_err(|e| {
905 tracing::warn!("Failed to parse base URL response from {}: {}", url, e);
906 RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
907 });
908 if parsed.is_ok() {
909 return parsed;
910 } else {
911 last_err = Some(parsed.err().unwrap());
912 }
913 }
914
915 Err(last_err.unwrap_or_else(|| {
916 RunbeamError::Api(ApiError::Request(
917 "Base URL discovery failed for all candidates".to_string(),
918 ))
919 }))
920 }
921
922 pub async fn discover_base_url(&self, token: impl Into<String>) -> Result<Self, RunbeamError> {
924 let resp = self.get_base_url(token).await?;
925 let discovered = resp.full_url.unwrap_or(resp.base_url);
926 tracing::info!("Discovered Runbeam API base URL: {}", discovered);
927 Ok(Self::new(discovered))
928 }
929
930 pub async fn acknowledge_changes(
958 &self,
959 token: impl Into<String>,
960 change_ids: Vec<String>,
961 ) -> Result<crate::runbeam_api::resources::AcknowledgeChangesResponse, RunbeamError> {
962 let url = format!("{}/harmony/changes/acknowledge", self.base_url);
963
964 tracing::info!("Acknowledging {} changes", change_ids.len());
965 tracing::debug!("Change IDs: {:?}", change_ids);
966
967 let payload = crate::runbeam_api::resources::AcknowledgeChangesRequest { change_ids };
968
969 let response = self
970 .client
971 .post(&url)
972 .header("Authorization", format!("Bearer {}", token.into()))
973 .header("Content-Type", "application/json")
974 .json(&payload)
975 .send()
976 .await
977 .map_err(ApiError::from)?;
978
979 if !response.status().is_success() {
980 let status = response.status();
981 let error_body = response
982 .text()
983 .await
984 .unwrap_or_else(|_| "Unknown error".to_string());
985 tracing::error!(
986 "Failed to acknowledge changes: HTTP {} - {}",
987 status,
988 error_body
989 );
990 return Err(RunbeamError::Api(ApiError::Http {
991 status: status.as_u16(),
992 message: error_body,
993 }));
994 }
995
996 response.json().await.map_err(|e| {
997 tracing::error!("Failed to parse acknowledge response: {}", e);
998 RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
999 })
1000 }
1001
1002 pub async fn mark_change_applied(
1028 &self,
1029 token: impl Into<String>,
1030 change_id: impl Into<String>,
1031 ) -> Result<crate::runbeam_api::resources::ChangeAppliedResponse, RunbeamError> {
1032 let change_id = change_id.into();
1033 let url = format!("{}/harmony/change/{}/applied", self.base_url, change_id);
1034
1035 tracing::info!("Marking change {} as applied", change_id);
1036
1037 let response = self
1038 .client
1039 .post(&url)
1040 .header("Authorization", format!("Bearer {}", token.into()))
1041 .send()
1042 .await
1043 .map_err(ApiError::from)?;
1044
1045 if !response.status().is_success() {
1046 let status = response.status();
1047 let error_body = response
1048 .text()
1049 .await
1050 .unwrap_or_else(|_| "Unknown error".to_string());
1051 tracing::error!(
1052 "Failed to mark change as applied: HTTP {} - {}",
1053 status,
1054 error_body
1055 );
1056 return Err(RunbeamError::Api(ApiError::Http {
1057 status: status.as_u16(),
1058 message: error_body,
1059 }));
1060 }
1061
1062 response.json().await.map_err(|e| {
1063 tracing::error!("Failed to parse applied response: {}", e);
1064 RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
1065 })
1066 }
1067
1068 pub async fn mark_change_failed(
1101 &self,
1102 token: impl Into<String>,
1103 change_id: impl Into<String>,
1104 error: String,
1105 details: Option<Vec<String>>,
1106 ) -> Result<crate::runbeam_api::resources::ChangeFailedResponse, RunbeamError> {
1107 let change_id = change_id.into();
1108 let url = format!("{}/harmony/change/{}/failed", self.base_url, change_id);
1109
1110 tracing::warn!("Marking change {} as failed: {}", change_id, error);
1111 if let Some(ref details) = details {
1112 tracing::debug!("Failure details: {:?}", details);
1113 }
1114
1115 let payload = crate::runbeam_api::resources::ChangeFailedRequest { error, details };
1116
1117 let response = self
1118 .client
1119 .post(&url)
1120 .header("Authorization", format!("Bearer {}", token.into()))
1121 .header("Content-Type", "application/json")
1122 .json(&payload)
1123 .send()
1124 .await
1125 .map_err(ApiError::from)?;
1126
1127 if !response.status().is_success() {
1128 let status = response.status();
1129 let error_body = response
1130 .text()
1131 .await
1132 .unwrap_or_else(|_| "Unknown error".to_string());
1133 tracing::error!(
1134 "Failed to mark change as failed: HTTP {} - {}",
1135 status,
1136 error_body
1137 );
1138 return Err(RunbeamError::Api(ApiError::Http {
1139 status: status.as_u16(),
1140 message: error_body,
1141 }));
1142 }
1143
1144 response.json().await.map_err(|e| {
1145 tracing::error!("Failed to parse failed response: {}", e);
1146 RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
1147 })
1148 }
1149
1150 pub async fn store_config(
1225 &self,
1226 token: impl Into<String>,
1227 config_type: impl Into<String>,
1228 id: Option<String>,
1229 config: impl Into<String>,
1230 ) -> Result<StoreConfigResponse, RunbeamError> {
1231 let config_type = config_type.into();
1232 let config = config.into();
1233 let url = format!("{}/harmony/update", self.base_url);
1234
1235 tracing::info!(
1236 "Storing {} configuration to Runbeam Cloud (id: {:?})",
1237 config_type,
1238 id
1239 );
1240 tracing::debug!("Configuration length: {} bytes", config.len());
1241
1242 let payload = StoreConfigRequest {
1243 config_type: config_type.clone(),
1244 id: id.clone(),
1245 config,
1246 };
1247
1248 let response = self
1249 .client
1250 .post(&url)
1251 .header("Authorization", format!("Bearer {}", token.into()))
1252 .header("Content-Type", "application/json")
1253 .json(&payload)
1254 .send()
1255 .await
1256 .map_err(|e| {
1257 tracing::error!("Failed to send store config request: {}", e);
1258 ApiError::from(e)
1259 })?;
1260
1261 let status = response.status();
1262 tracing::debug!("Received response with status: {}", status);
1263
1264 if !status.is_success() {
1266 let error_body = response
1267 .text()
1268 .await
1269 .unwrap_or_else(|_| "Unknown error".to_string());
1270
1271 tracing::error!(
1272 "Store config failed: HTTP {} - {}",
1273 status.as_u16(),
1274 error_body
1275 );
1276
1277 return Err(RunbeamError::Api(ApiError::Http {
1278 status: status.as_u16(),
1279 message: error_body,
1280 }));
1281 }
1282
1283 let body_text = response.text().await.map_err(|e| {
1285 tracing::error!("Failed to read response body: {}", e);
1286 ApiError::Network(format!("Failed to read response body: {}", e))
1287 })?;
1288
1289 let response_data =
1290 serde_json::from_str::<StoreConfigResponse>(&body_text).map_err(|e| {
1291 tracing::error!("Failed to parse store config response: {}", e);
1292 tracing::error!("Response body was: {}", body_text);
1293 ApiError::Parse(format!("Failed to parse response: {}", e))
1294 })?;
1295
1296 tracing::info!(
1297 "Configuration stored successfully: type={}, id={:?}, action={}",
1298 config_type,
1299 id,
1300 response_data.data.action
1301 );
1302
1303 Ok(response_data)
1304 }
1305
1306 pub async fn request_mesh_token(
1348 &self,
1349 token: impl Into<String>,
1350 mesh_id: impl Into<String>,
1351 destination_url: impl Into<String>,
1352 ) -> Result<crate::runbeam_api::resources::MeshTokenResponse, RunbeamError> {
1353 let mesh_id = mesh_id.into();
1354 let destination_url = destination_url.into();
1355 let url = format!("{}/harmony/mesh/token", self.base_url);
1356
1357 tracing::debug!(
1358 "Requesting mesh token: mesh_id={}, destination={}",
1359 mesh_id,
1360 destination_url
1361 );
1362
1363 let payload = crate::runbeam_api::resources::MeshTokenRequest {
1364 mesh_id: mesh_id.clone(),
1365 destination_url: destination_url.clone(),
1366 };
1367
1368 let response = self
1369 .client
1370 .post(&url)
1371 .header("Authorization", format!("Bearer {}", token.into()))
1372 .header("Content-Type", "application/json")
1373 .json(&payload)
1374 .send()
1375 .await
1376 .map_err(|e| {
1377 tracing::error!("Failed to send mesh token request: {}", e);
1378 ApiError::from(e)
1379 })?;
1380
1381 let status = response.status();
1382 tracing::debug!("Received response with status: {}", status);
1383
1384 if !status.is_success() {
1385 let error_body = response
1386 .text()
1387 .await
1388 .unwrap_or_else(|_| "Unknown error".to_string());
1389
1390 tracing::error!(
1391 "Mesh token request failed: HTTP {} - {}",
1392 status.as_u16(),
1393 error_body
1394 );
1395
1396 return Err(RunbeamError::Api(ApiError::Http {
1397 status: status.as_u16(),
1398 message: error_body,
1399 }));
1400 }
1401
1402 let token_response: crate::runbeam_api::resources::MeshTokenResponse =
1403 response.json().await.map_err(|e| {
1404 tracing::error!("Failed to parse mesh token response: {}", e);
1405 ApiError::Parse(format!("Failed to parse response: {}", e))
1406 })?;
1407
1408 tracing::info!(
1409 "Mesh token obtained: mesh_id={}, expires_at={}",
1410 token_response.mesh_id,
1411 token_response.expires_at
1412 );
1413
1414 Ok(token_response)
1415 }
1416
1417 pub async fn resolve_reference(
1457 &self,
1458 token: impl Into<String>,
1459 reference: impl Into<String>,
1460 ) -> Result<crate::runbeam_api::types::ResolveResourceResponse, RunbeamError> {
1461 let reference = reference.into();
1462 let url = format!(
1463 "{}/harmony/resources/resolve?ref={}",
1464 self.base_url,
1465 urlencoding::encode(&reference)
1466 );
1467
1468 tracing::debug!("Resolving resource reference: {}", reference);
1469
1470 let response = self
1471 .client
1472 .get(&url)
1473 .header("Authorization", format!("Bearer {}", token.into()))
1474 .send()
1475 .await
1476 .map_err(|e| {
1477 tracing::error!("Failed to send resolve request: {}", e);
1478 ApiError::from(e)
1479 })?;
1480
1481 let status = response.status();
1482 tracing::debug!("Received response with status: {}", status);
1483
1484 if !status.is_success() {
1485 let error_body = response
1486 .text()
1487 .await
1488 .unwrap_or_else(|_| "Unknown error".to_string());
1489
1490 tracing::error!(
1491 "Resource resolution failed: HTTP {} - {}",
1492 status.as_u16(),
1493 error_body
1494 );
1495
1496 return Err(RunbeamError::Api(ApiError::Http {
1497 status: status.as_u16(),
1498 message: error_body,
1499 }));
1500 }
1501
1502 let resolve_response: crate::runbeam_api::types::ResolveResourceResponse =
1503 response.json().await.map_err(|e| {
1504 tracing::error!("Failed to parse resolve response: {}", e);
1505 ApiError::Parse(format!("Failed to parse response: {}", e))
1506 })?;
1507
1508 tracing::info!(
1509 "Resource resolved: {} ({}) from provider {}",
1510 resolve_response.data.name,
1511 resolve_response.data.resource_type,
1512 resolve_response.meta.provider
1513 );
1514
1515 Ok(resolve_response)
1516 }
1517}
1518
1519#[cfg(test)]
1520mod tests {
1521 use super::*;
1522
1523 #[test]
1524 fn test_client_creation() {
1525 let client = RunbeamClient::new("http://example.com");
1526 assert_eq!(client.base_url(), "http://example.com");
1527 }
1528
1529 #[test]
1530 fn test_client_creation_with_string() {
1531 let base_url = String::from("http://example.com");
1532 let client = RunbeamClient::new(base_url);
1533 assert_eq!(client.base_url(), "http://example.com");
1534 }
1535
1536 #[test]
1537 fn test_authorize_request_serialization() {
1538 let request = AuthorizeRequest {
1539 token: "test_token".to_string(),
1540 gateway_code: "gw123".to_string(),
1541 machine_public_key: Some("pubkey123".to_string()),
1542 metadata: None,
1543 };
1544
1545 let json = serde_json::to_string(&request).unwrap();
1546 assert!(json.contains("\"token\":\"test_token\""));
1547 assert!(json.contains("\"gateway_code\":\"gw123\""));
1548 assert!(json.contains("\"machine_public_key\":\"pubkey123\""));
1549 }
1550
1551 #[test]
1552 fn test_authorize_request_serialization_without_optional_fields() {
1553 let request = AuthorizeRequest {
1554 token: "test_token".to_string(),
1555 gateway_code: "gw123".to_string(),
1556 machine_public_key: None,
1557 metadata: None,
1558 };
1559
1560 let json = serde_json::to_string(&request).unwrap();
1561 assert!(json.contains("\"token\":\"test_token\""));
1562 assert!(json.contains("\"gateway_code\":\"gw123\""));
1563 assert!(!json.contains("machine_public_key"));
1565 assert!(!json.contains("metadata"));
1566 }
1567
1568 #[test]
1569 fn test_change_serialization() {
1570 use crate::runbeam_api::resources::Change;
1571
1572 let change_metadata = Change {
1574 id: "01JBXXXXXXXXXXXXXXXXXXXXXXXXXX".to_string(),
1575 status: Some("pending".to_string()),
1576 resource_type: "gateway".to_string(),
1577 gateway_id: "01JBXXXXXXXXXXXXXXXXXXXXXXXXXX".to_string(),
1578 pipeline_id: None,
1579 toml_config: None,
1580 metadata: None,
1581 created_at: "2025-01-07T01:00:00+00:00".to_string(),
1582 acknowledged_at: None,
1583 applied_at: None,
1584 failed_at: None,
1585 error_message: None,
1586 error_details: None,
1587 };
1588
1589 let json = serde_json::to_string(&change_metadata).unwrap();
1590 assert!(json.contains("\"id\":\"01JBXXXXXXXXXXXXXXXXXXXXXXXXXX\""));
1591 assert!(json.contains("\"gateway_id\":\"01JBXXXXXXXXXXXXXXXXXXXXXXXXXX\""));
1592 assert!(json.contains("\"type\":\"gateway\""));
1593
1594 let deserialized: Change = serde_json::from_str(&json).unwrap();
1596 assert_eq!(deserialized.id, "01JBXXXXXXXXXXXXXXXXXXXXXXXXXX");
1597 assert_eq!(deserialized.status, Some("pending".to_string()));
1598 assert_eq!(deserialized.resource_type, "gateway");
1599
1600 let change_detail = Change {
1602 id: "01JBXXXXXXXXXXXXXXXXXXXXXXXXXX".to_string(),
1603 status: Some("applied".to_string()),
1604 resource_type: "gateway".to_string(),
1605 gateway_id: "01JBXXXXXXXXXXXXXXXXXXXXXXXXXX".to_string(),
1606 pipeline_id: None,
1607 toml_config: Some("[proxy]\nname = \"test\"".to_string()),
1608 metadata: Some(serde_json::json!({"gateway_name": "test-gateway"})),
1609 created_at: "2025-01-07T01:00:00+00:00".to_string(),
1610 acknowledged_at: Some("2025-01-07T01:00:05+00:00".to_string()),
1611 applied_at: Some("2025-01-07T01:00:10+00:00".to_string()),
1612 failed_at: None,
1613 error_message: None,
1614 error_details: None,
1615 };
1616
1617 let json = serde_json::to_string(&change_detail).unwrap();
1618 assert!(json.contains("toml_config"));
1619 assert!(json.contains("acknowledged_at"));
1620 assert!(json.contains("applied_at"));
1621
1622 let deserialized: Change = serde_json::from_str(&json).unwrap();
1624 assert!(deserialized.toml_config.is_some());
1625 assert!(deserialized.acknowledged_at.is_some());
1626 assert!(deserialized.applied_at.is_some());
1627 }
1628
1629 #[test]
1630 fn test_acknowledge_changes_request_serialization() {
1631 use crate::runbeam_api::resources::AcknowledgeChangesRequest;
1632
1633 let request = AcknowledgeChangesRequest {
1634 change_ids: vec![
1635 "change-1".to_string(),
1636 "change-2".to_string(),
1637 "change-3".to_string(),
1638 ],
1639 };
1640
1641 let json = serde_json::to_string(&request).unwrap();
1642 assert!(json.contains("\"change_ids\""));
1643 assert!(json.contains("\"change-1\""));
1644 assert!(json.contains("\"change-2\""));
1645 assert!(json.contains("\"change-3\""));
1646
1647 let deserialized: AcknowledgeChangesRequest = serde_json::from_str(&json).unwrap();
1649 assert_eq!(deserialized.change_ids.len(), 3);
1650 assert_eq!(deserialized.change_ids[0], "change-1");
1651 }
1652
1653 #[test]
1654 fn test_change_failed_request_serialization() {
1655 use crate::runbeam_api::resources::ChangeFailedRequest;
1656
1657 let request_with_details = ChangeFailedRequest {
1659 error: "Configuration parse error".to_string(),
1660 details: Some(vec![
1661 "Invalid JSON at line 42".to_string(),
1662 "Missing required field 'name'".to_string(),
1663 ]),
1664 };
1665
1666 let json = serde_json::to_string(&request_with_details).unwrap();
1667 assert!(json.contains("\"error\":\"Configuration parse error\""));
1668 assert!(json.contains("\"details\""));
1669 assert!(json.contains("Invalid JSON at line 42"));
1670
1671 let request_without_details = ChangeFailedRequest {
1673 error: "Unknown error".to_string(),
1674 details: None,
1675 };
1676
1677 let json = serde_json::to_string(&request_without_details).unwrap();
1678 assert!(json.contains("\"error\":\"Unknown error\""));
1679 assert!(!json.contains("\"details\"")); let deserialized: ChangeFailedRequest =
1683 serde_json::from_str(&serde_json::to_string(&request_with_details).unwrap()).unwrap();
1684 assert_eq!(deserialized.error, "Configuration parse error");
1685 assert!(deserialized.details.is_some());
1686 assert_eq!(deserialized.details.unwrap().len(), 2);
1687 }
1688
1689 #[test]
1690 fn test_base_url_response_serialization() {
1691 use crate::runbeam_api::resources::BaseUrlResponse;
1692
1693 let response = BaseUrlResponse {
1694 base_url: "https://api.runbeam.io".to_string(),
1695 changes_path: Some("/api/changes".to_string()),
1696 full_url: Some("https://api.runbeam.io/api/changes".to_string()),
1697 };
1698
1699 let json = serde_json::to_string(&response).unwrap();
1700 assert!(json.contains("\"base_url\":\"https://api.runbeam.io\""));
1701
1702 let deserialized: BaseUrlResponse = serde_json::from_str(&json).unwrap();
1704 assert_eq!(deserialized.base_url, "https://api.runbeam.io");
1705 assert_eq!(deserialized.changes_path, Some("/api/changes".to_string()));
1706 assert_eq!(
1707 deserialized.full_url,
1708 Some("https://api.runbeam.io/api/changes".to_string())
1709 );
1710 }
1711
1712 #[test]
1713 fn test_store_config_request_serialization_with_id() {
1714 let request = StoreConfigRequest {
1715 config_type: "gateway".to_string(),
1716 id: Some("01k8ek6h9aahhnrv3benret1nn".to_string()),
1717 config: "[proxy]\nid = \"test\"\n".to_string(),
1718 };
1719
1720 let json = serde_json::to_string(&request).unwrap();
1721 assert!(json.contains("\"type\":\"gateway\""));
1723 assert!(json.contains("\"id\":\"01k8ek6h9aahhnrv3benret1nn\""));
1724 assert!(json.contains("\"config\":"));
1725 assert!(json.contains("[proxy]"));
1726
1727 let deserialized: StoreConfigRequest = serde_json::from_str(&json).unwrap();
1729 assert_eq!(deserialized.config_type, "gateway");
1730 assert_eq!(
1731 deserialized.id,
1732 Some("01k8ek6h9aahhnrv3benret1nn".to_string())
1733 );
1734 }
1735
1736 #[test]
1737 fn test_store_config_request_serialization_without_id() {
1738 let request = StoreConfigRequest {
1739 config_type: "pipeline".to_string(),
1740 id: None,
1741 config: "[pipeline]\nname = \"test\"\n".to_string(),
1742 };
1743
1744 let json = serde_json::to_string(&request).unwrap();
1745 assert!(json.contains("\"type\":\"pipeline\""));
1746 assert!(json.contains("\"config\":"));
1747 assert!(!json.contains("\"id\""));
1749
1750 let deserialized: StoreConfigRequest = serde_json::from_str(&json).unwrap();
1752 assert_eq!(deserialized.config_type, "pipeline");
1753 assert_eq!(deserialized.id, None);
1754 }
1755
1756 #[test]
1757 fn test_store_config_request_field_rename() {
1758 let json = r#"{"type":"transform","config":"[transform]\nname = \"test\"\n"}"#;
1760 let request: StoreConfigRequest = serde_json::from_str(json).unwrap();
1761 assert_eq!(request.config_type, "transform");
1762 assert_eq!(request.id, None);
1763
1764 let serialized = serde_json::to_string(&request).unwrap();
1766 assert!(serialized.contains("\"type\":"));
1767 assert!(!serialized.contains("\"config_type\":"));
1768 }
1769
1770 #[test]
1771 fn test_store_config_response_serialization() {
1772 use crate::runbeam_api::types::StoreConfigModel;
1773
1774 let response = StoreConfigResponse {
1775 success: true,
1776 message: "Configuration stored successfully".to_string(),
1777 data: StoreConfigModel {
1778 id: "01k9npa4tatmwddk66xxpcr2r0".to_string(),
1779 model_type: "gateway".to_string(),
1780 action: "updated".to_string(),
1781 },
1782 };
1783
1784 let json = serde_json::to_string(&response).unwrap();
1785 assert!(json.contains("\"success\":true"));
1786 assert!(json.contains("Configuration stored successfully"));
1787
1788 let deserialized: StoreConfigResponse = serde_json::from_str(&json).unwrap();
1790 assert_eq!(deserialized.success, true);
1791 assert_eq!(deserialized.message, "Configuration stored successfully");
1792 assert_eq!(deserialized.data.id, "01k9npa4tatmwddk66xxpcr2r0");
1793 }
1794
1795 #[test]
1796 fn test_acknowledge_changes_response_serialization() {
1797 use crate::runbeam_api::resources::AcknowledgeChangesResponse;
1798
1799 let response = AcknowledgeChangesResponse {
1801 acknowledged: vec![
1802 "change-1".to_string(),
1803 "change-2".to_string(),
1804 "change-3".to_string(),
1805 ],
1806 failed: vec![],
1807 };
1808
1809 let json = serde_json::to_string(&response).unwrap();
1810 assert!(json.contains("\"acknowledged\":"));
1811 assert!(json.contains("\"failed\":"));
1812 assert!(json.contains("change-1"));
1813
1814 let deserialized: AcknowledgeChangesResponse = serde_json::from_str(&json).unwrap();
1816 assert_eq!(deserialized.acknowledged.len(), 3);
1817 assert_eq!(deserialized.failed.len(), 0);
1818
1819 let response_with_failures = AcknowledgeChangesResponse {
1821 acknowledged: vec!["change-1".to_string()],
1822 failed: vec!["change-2".to_string(), "change-3".to_string()],
1823 };
1824
1825 let json = serde_json::to_string(&response_with_failures).unwrap();
1826 let deserialized: AcknowledgeChangesResponse = serde_json::from_str(&json).unwrap();
1827 assert_eq!(deserialized.acknowledged.len(), 1);
1828 assert_eq!(deserialized.failed.len(), 2);
1829 }
1830
1831 #[test]
1832 fn test_change_status_response_serialization() {
1833 use crate::runbeam_api::resources::{
1834 ChangeAppliedResponse, ChangeFailedResponse, ChangeStatusResponse,
1835 };
1836
1837 let response = ChangeStatusResponse {
1839 success: true,
1840 message: "Change marked as applied".to_string(),
1841 };
1842
1843 let json = serde_json::to_string(&response).unwrap();
1844 assert!(json.contains("\"success\":true"));
1845 assert!(json.contains("\"message\":\"Change marked as applied\""));
1846
1847 let deserialized: ChangeStatusResponse = serde_json::from_str(&json).unwrap();
1849 assert_eq!(deserialized.success, true);
1850 assert_eq!(deserialized.message, "Change marked as applied");
1851
1852 let applied_response: ChangeAppliedResponse = ChangeStatusResponse {
1854 success: true,
1855 message: "Change marked as applied".to_string(),
1856 };
1857
1858 let json = serde_json::to_string(&applied_response).unwrap();
1859 let deserialized: ChangeAppliedResponse = serde_json::from_str(&json).unwrap();
1860 assert_eq!(deserialized.success, true);
1861
1862 let failed_response: ChangeFailedResponse = ChangeStatusResponse {
1864 success: true,
1865 message: "Change marked as failed".to_string(),
1866 };
1867
1868 let json = serde_json::to_string(&failed_response).unwrap();
1869 let deserialized: ChangeFailedResponse = serde_json::from_str(&json).unwrap();
1870 assert_eq!(deserialized.success, true);
1871 assert_eq!(deserialized.message, "Change marked as failed");
1872 }
1873}