1use crate::runbeam_api::types::{ApiError, AuthorizeResponse, RunbeamError};
2use serde::Serialize;
3
4#[derive(Debug, Clone)]
9pub struct RunbeamClient {
10 base_url: String,
12 client: reqwest::Client,
14}
15
16#[derive(Debug, Serialize)]
18struct AuthorizeRequest {
19 token: String,
21 gateway_code: String,
23 #[serde(skip_serializing_if = "Option::is_none")]
25 machine_public_key: Option<String>,
26 #[serde(skip_serializing_if = "Option::is_none")]
28 metadata: Option<Vec<String>>,
29}
30
31impl RunbeamClient {
32 pub fn new(base_url: impl Into<String>) -> Self {
46 let base_url = base_url.into();
47 tracing::debug!("Creating RunbeamClient with base URL: {}", base_url);
48
49 Self {
50 base_url,
51 client: reqwest::Client::new(),
52 }
53 }
54
55 pub async fn authorize_gateway(
112 &self,
113 user_token: impl Into<String>,
114 gateway_code: impl Into<String>,
115 machine_public_key: Option<String>,
116 metadata: Option<Vec<String>>,
117 ) -> Result<AuthorizeResponse, RunbeamError> {
118 let user_token = user_token.into();
119 let gateway_code = gateway_code.into();
120
121 tracing::info!(
122 "Authorizing gateway with Runbeam Cloud: gateway_code={}",
123 gateway_code
124 );
125
126 let url = format!("{}/api/harmony/authorize", self.base_url);
128
129 let payload = AuthorizeRequest {
131 token: user_token.clone(),
132 gateway_code: gateway_code.clone(),
133 machine_public_key,
134 metadata,
135 };
136
137 tracing::debug!("Sending authorization request to: {}", url);
138
139 let response = self
141 .client
142 .post(&url)
143 .header("Authorization", format!("Bearer {}", user_token))
144 .header("Content-Type", "application/json")
145 .json(&payload)
146 .send()
147 .await
148 .map_err(|e| {
149 tracing::error!("Failed to send authorization request: {}", e);
150 ApiError::from(e)
151 })?;
152
153 let status = response.status();
154 tracing::debug!("Received response with status: {}", status);
155
156 if !status.is_success() {
158 let error_body = response
159 .text()
160 .await
161 .unwrap_or_else(|_| "Unknown error".to_string());
162
163 tracing::error!(
164 "Authorization failed: HTTP {} - {}",
165 status.as_u16(),
166 error_body
167 );
168
169 return Err(RunbeamError::Api(ApiError::Http {
170 status: status.as_u16(),
171 message: error_body,
172 }));
173 }
174
175 let auth_response: AuthorizeResponse = response.json().await.map_err(|e| {
177 tracing::error!("Failed to parse authorization response: {}", e);
178 ApiError::Parse(format!("Failed to parse response JSON: {}", e))
179 })?;
180
181 tracing::info!(
182 "Gateway authorized successfully: gateway_id={}, expires_at={}",
183 auth_response.gateway.id,
184 auth_response.expires_at
185 );
186
187 tracing::debug!(
188 "Machine token length: {}",
189 auth_response.machine_token.len()
190 );
191 tracing::debug!("Gateway abilities: {:?}", auth_response.abilities);
192
193 Ok(auth_response)
194 }
195
196 pub fn base_url(&self) -> &str {
198 &self.base_url
199 }
200
201 pub async fn list_gateways(
214 &self,
215 token: impl Into<String>,
216 ) -> Result<
217 crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Gateway>,
218 RunbeamError,
219 > {
220 let url = format!("{}/api/gateways", self.base_url);
221
222 let response = self
223 .client
224 .get(&url)
225 .header("Authorization", format!("Bearer {}", token.into()))
226 .send()
227 .await
228 .map_err(ApiError::from)?;
229
230 if !response.status().is_success() {
231 let status = response.status();
232 let error_body = response
233 .text()
234 .await
235 .unwrap_or_else(|_| "Unknown error".to_string());
236 return Err(RunbeamError::Api(ApiError::Http {
237 status: status.as_u16(),
238 message: error_body,
239 }));
240 }
241
242 response.json().await.map_err(|e| {
243 RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
244 })
245 }
246
247 pub async fn get_gateway(
259 &self,
260 token: impl Into<String>,
261 gateway_id: impl Into<String>,
262 ) -> Result<
263 crate::runbeam_api::resources::ResourceResponse<crate::runbeam_api::resources::Gateway>,
264 RunbeamError,
265 > {
266 let url = format!("{}/api/gateways/{}", self.base_url, gateway_id.into());
267
268 let response = self
269 .client
270 .get(&url)
271 .header("Authorization", format!("Bearer {}", token.into()))
272 .send()
273 .await
274 .map_err(ApiError::from)?;
275
276 if !response.status().is_success() {
277 let status = response.status();
278 let error_body = response
279 .text()
280 .await
281 .unwrap_or_else(|_| "Unknown error".to_string());
282 return Err(RunbeamError::Api(ApiError::Http {
283 status: status.as_u16(),
284 message: error_body,
285 }));
286 }
287
288 response.json().await.map_err(|e| {
289 RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
290 })
291 }
292
293 pub async fn list_services(
306 &self,
307 token: impl Into<String>,
308 ) -> Result<
309 crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Service>,
310 RunbeamError,
311 > {
312 let url = format!("{}/api/services", self.base_url);
313
314 let response = self
315 .client
316 .get(&url)
317 .header("Authorization", format!("Bearer {}", token.into()))
318 .send()
319 .await
320 .map_err(ApiError::from)?;
321
322 if !response.status().is_success() {
323 let status = response.status();
324 let error_body = response
325 .text()
326 .await
327 .unwrap_or_else(|_| "Unknown error".to_string());
328 return Err(RunbeamError::Api(ApiError::Http {
329 status: status.as_u16(),
330 message: error_body,
331 }));
332 }
333
334 response.json().await.map_err(|e| {
335 RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
336 })
337 }
338
339 pub async fn get_service(
351 &self,
352 token: impl Into<String>,
353 service_id: impl Into<String>,
354 ) -> Result<
355 crate::runbeam_api::resources::ResourceResponse<crate::runbeam_api::resources::Service>,
356 RunbeamError,
357 > {
358 let url = format!("{}/api/services/{}", self.base_url, service_id.into());
359
360 let response = self
361 .client
362 .get(&url)
363 .header("Authorization", format!("Bearer {}", token.into()))
364 .send()
365 .await
366 .map_err(ApiError::from)?;
367
368 if !response.status().is_success() {
369 let status = response.status();
370 let error_body = response
371 .text()
372 .await
373 .unwrap_or_else(|_| "Unknown error".to_string());
374 return Err(RunbeamError::Api(ApiError::Http {
375 status: status.as_u16(),
376 message: error_body,
377 }));
378 }
379
380 response.json().await.map_err(|e| {
381 RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
382 })
383 }
384
385 pub async fn list_endpoints(
396 &self,
397 token: impl Into<String>,
398 ) -> Result<
399 crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Endpoint>,
400 RunbeamError,
401 > {
402 let url = format!("{}/api/endpoints", self.base_url);
403
404 let response = self
405 .client
406 .get(&url)
407 .header("Authorization", format!("Bearer {}", token.into()))
408 .send()
409 .await
410 .map_err(ApiError::from)?;
411
412 if !response.status().is_success() {
413 let status = response.status();
414 let error_body = response
415 .text()
416 .await
417 .unwrap_or_else(|_| "Unknown error".to_string());
418 return Err(RunbeamError::Api(ApiError::Http {
419 status: status.as_u16(),
420 message: error_body,
421 }));
422 }
423
424 response.json().await.map_err(|e| {
425 RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
426 })
427 }
428
429 pub async fn list_backends(
440 &self,
441 token: impl Into<String>,
442 ) -> Result<
443 crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Backend>,
444 RunbeamError,
445 > {
446 let url = format!("{}/api/backends", self.base_url);
447
448 let response = self
449 .client
450 .get(&url)
451 .header("Authorization", format!("Bearer {}", token.into()))
452 .send()
453 .await
454 .map_err(ApiError::from)?;
455
456 if !response.status().is_success() {
457 let status = response.status();
458 let error_body = response
459 .text()
460 .await
461 .unwrap_or_else(|_| "Unknown error".to_string());
462 return Err(RunbeamError::Api(ApiError::Http {
463 status: status.as_u16(),
464 message: error_body,
465 }));
466 }
467
468 response.json().await.map_err(|e| {
469 RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
470 })
471 }
472
473 pub async fn list_pipelines(
484 &self,
485 token: impl Into<String>,
486 ) -> Result<
487 crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Pipeline>,
488 RunbeamError,
489 > {
490 let url = format!("{}/api/pipelines", self.base_url);
491
492 let response = self
493 .client
494 .get(&url)
495 .header("Authorization", format!("Bearer {}", token.into()))
496 .send()
497 .await
498 .map_err(ApiError::from)?;
499
500 if !response.status().is_success() {
501 let status = response.status();
502 let error_body = response
503 .text()
504 .await
505 .unwrap_or_else(|_| "Unknown error".to_string());
506 return Err(RunbeamError::Api(ApiError::Http {
507 status: status.as_u16(),
508 message: error_body,
509 }));
510 }
511
512 response.json().await.map_err(|e| {
513 RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
514 })
515 }
516
517 pub async fn get_base_url(
545 &self,
546 token: impl Into<String>,
547 ) -> Result<crate::runbeam_api::resources::BaseUrlResponse, RunbeamError> {
548 let url = format!("{}/gateway/base-url", self.base_url);
549
550 tracing::debug!("Getting base URL from: {}", 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 tracing::error!("Failed to get base URL: HTTP {} - {}", status, error_body);
567 return Err(RunbeamError::Api(ApiError::Http {
568 status: status.as_u16(),
569 message: error_body,
570 }));
571 }
572
573 response.json().await.map_err(|e| {
574 tracing::error!("Failed to parse base URL response: {}", e);
575 RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
576 })
577 }
578
579 pub async fn list_changes(
605 &self,
606 token: impl Into<String>,
607 ) -> Result<
608 crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Change>,
609 RunbeamError,
610 > {
611 let url = format!("{}/gateway/changes", self.base_url);
612
613 tracing::debug!("Listing changes from: {}", url);
614
615 let response = self
616 .client
617 .get(&url)
618 .header("Authorization", format!("Bearer {}", token.into()))
619 .send()
620 .await
621 .map_err(ApiError::from)?;
622
623 if !response.status().is_success() {
624 let status = response.status();
625 let error_body = response
626 .text()
627 .await
628 .unwrap_or_else(|_| "Unknown error".to_string());
629 tracing::error!("Failed to list changes: HTTP {} - {}", status, error_body);
630 return Err(RunbeamError::Api(ApiError::Http {
631 status: status.as_u16(),
632 message: error_body,
633 }));
634 }
635
636 response.json().await.map_err(|e| {
637 tracing::error!("Failed to parse changes response: {}", e);
638 RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
639 })
640 }
641
642 pub async fn get_change(
668 &self,
669 token: impl Into<String>,
670 change_id: impl Into<String>,
671 ) -> Result<
672 crate::runbeam_api::resources::ResourceResponse<crate::runbeam_api::resources::Change>,
673 RunbeamError,
674 > {
675 let change_id = change_id.into();
676 let url = format!("{}/gateway/changes/{}", self.base_url, change_id);
677
678 tracing::debug!("Getting change {} from: {}", change_id, url);
679
680 let response = self
681 .client
682 .get(&url)
683 .header("Authorization", format!("Bearer {}", token.into()))
684 .send()
685 .await
686 .map_err(ApiError::from)?;
687
688 if !response.status().is_success() {
689 let status = response.status();
690 let error_body = response
691 .text()
692 .await
693 .unwrap_or_else(|_| "Unknown error".to_string());
694 tracing::error!("Failed to get change: HTTP {} - {}", status, error_body);
695 return Err(RunbeamError::Api(ApiError::Http {
696 status: status.as_u16(),
697 message: error_body,
698 }));
699 }
700
701 response.json().await.map_err(|e| {
702 tracing::error!("Failed to parse change response: {}", e);
703 RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
704 })
705 }
706
707 pub async fn acknowledge_changes(
735 &self,
736 token: impl Into<String>,
737 change_ids: Vec<String>,
738 ) -> Result<serde_json::Value, RunbeamError> {
739 let url = format!("{}/gateway/changes/acknowledge", self.base_url);
740
741 tracing::info!("Acknowledging {} changes", change_ids.len());
742 tracing::debug!("Change IDs: {:?}", change_ids);
743
744 let payload = crate::runbeam_api::resources::AcknowledgeChangesRequest { change_ids };
745
746 let response = self
747 .client
748 .post(&url)
749 .header("Authorization", format!("Bearer {}", token.into()))
750 .header("Content-Type", "application/json")
751 .json(&payload)
752 .send()
753 .await
754 .map_err(ApiError::from)?;
755
756 if !response.status().is_success() {
757 let status = response.status();
758 let error_body = response
759 .text()
760 .await
761 .unwrap_or_else(|_| "Unknown error".to_string());
762 tracing::error!(
763 "Failed to acknowledge changes: HTTP {} - {}",
764 status,
765 error_body
766 );
767 return Err(RunbeamError::Api(ApiError::Http {
768 status: status.as_u16(),
769 message: error_body,
770 }));
771 }
772
773 response.json().await.map_err(|e| {
774 tracing::error!("Failed to parse acknowledge response: {}", e);
775 RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
776 })
777 }
778
779 pub async fn mark_change_applied(
805 &self,
806 token: impl Into<String>,
807 change_id: impl Into<String>,
808 ) -> Result<serde_json::Value, RunbeamError> {
809 let change_id = change_id.into();
810 let url = format!("{}/gateway/changes/{}/applied", self.base_url, change_id);
811
812 tracing::info!("Marking change {} as applied", change_id);
813
814 let response = self
815 .client
816 .post(&url)
817 .header("Authorization", format!("Bearer {}", token.into()))
818 .send()
819 .await
820 .map_err(ApiError::from)?;
821
822 if !response.status().is_success() {
823 let status = response.status();
824 let error_body = response
825 .text()
826 .await
827 .unwrap_or_else(|_| "Unknown error".to_string());
828 tracing::error!(
829 "Failed to mark change as applied: HTTP {} - {}",
830 status,
831 error_body
832 );
833 return Err(RunbeamError::Api(ApiError::Http {
834 status: status.as_u16(),
835 message: error_body,
836 }));
837 }
838
839 response.json().await.map_err(|e| {
840 tracing::error!("Failed to parse applied response: {}", e);
841 RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
842 })
843 }
844
845 pub async fn mark_change_failed(
878 &self,
879 token: impl Into<String>,
880 change_id: impl Into<String>,
881 error: String,
882 details: Option<Vec<String>>,
883 ) -> Result<serde_json::Value, RunbeamError> {
884 let change_id = change_id.into();
885 let url = format!("{}/gateway/changes/{}/failed", self.base_url, change_id);
886
887 tracing::warn!("Marking change {} as failed: {}", change_id, error);
888 if let Some(ref details) = details {
889 tracing::debug!("Failure details: {:?}", details);
890 }
891
892 let payload = crate::runbeam_api::resources::ChangeFailedRequest { error, details };
893
894 let response = self
895 .client
896 .post(&url)
897 .header("Authorization", format!("Bearer {}", token.into()))
898 .header("Content-Type", "application/json")
899 .json(&payload)
900 .send()
901 .await
902 .map_err(ApiError::from)?;
903
904 if !response.status().is_success() {
905 let status = response.status();
906 let error_body = response
907 .text()
908 .await
909 .unwrap_or_else(|_| "Unknown error".to_string());
910 tracing::error!(
911 "Failed to mark change as failed: HTTP {} - {}",
912 status,
913 error_body
914 );
915 return Err(RunbeamError::Api(ApiError::Http {
916 status: status.as_u16(),
917 message: error_body,
918 }));
919 }
920
921 response.json().await.map_err(|e| {
922 tracing::error!("Failed to parse failed response: {}", e);
923 RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
924 })
925 }
926}
927
928#[cfg(test)]
929mod tests {
930 use super::*;
931
932 #[test]
933 fn test_client_creation() {
934 let client = RunbeamClient::new("http://example.com");
935 assert_eq!(client.base_url(), "http://example.com");
936 }
937
938 #[test]
939 fn test_client_creation_with_string() {
940 let base_url = String::from("http://example.com");
941 let client = RunbeamClient::new(base_url);
942 assert_eq!(client.base_url(), "http://example.com");
943 }
944
945 #[test]
946 fn test_authorize_request_serialization() {
947 let request = AuthorizeRequest {
948 token: "test_token".to_string(),
949 gateway_code: "gw123".to_string(),
950 machine_public_key: Some("pubkey123".to_string()),
951 metadata: None,
952 };
953
954 let json = serde_json::to_string(&request).unwrap();
955 assert!(json.contains("\"token\":\"test_token\""));
956 assert!(json.contains("\"gateway_code\":\"gw123\""));
957 assert!(json.contains("\"machine_public_key\":\"pubkey123\""));
958 }
959
960 #[test]
961 fn test_authorize_request_serialization_without_optional_fields() {
962 let request = AuthorizeRequest {
963 token: "test_token".to_string(),
964 gateway_code: "gw123".to_string(),
965 machine_public_key: None,
966 metadata: None,
967 };
968
969 let json = serde_json::to_string(&request).unwrap();
970 assert!(json.contains("\"token\":\"test_token\""));
971 assert!(json.contains("\"gateway_code\":\"gw123\""));
972 assert!(!json.contains("machine_public_key"));
974 assert!(!json.contains("metadata"));
975 }
976
977 #[test]
978 fn test_change_serialization() {
979 use crate::runbeam_api::resources::Change;
980
981 let change = Change {
982 id: "change-123".to_string(),
983 resource_type: "change".to_string(),
984 gateway_id: "gateway-456".to_string(),
985 status: "pending".to_string(),
986 operation: "create".to_string(),
987 change_resource_type: "endpoint".to_string(),
988 resource_id: "endpoint-789".to_string(),
989 payload: serde_json::json!({"name": "test-endpoint"}),
990 error: None,
991 created_at: "2024-01-01T00:00:00Z".to_string(),
992 updated_at: "2024-01-01T00:00:00Z".to_string(),
993 };
994
995 let json = serde_json::to_string(&change).unwrap();
996 assert!(json.contains("\"id\":\"change-123\""));
997 assert!(json.contains("\"gateway_id\":\"gateway-456\""));
998 assert!(json.contains("\"status\":\"pending\""));
999 assert!(json.contains("\"operation\":\"create\""));
1000
1001 let deserialized: Change = serde_json::from_str(&json).unwrap();
1003 assert_eq!(deserialized.id, "change-123");
1004 assert_eq!(deserialized.status, "pending");
1005 }
1006
1007 #[test]
1008 fn test_acknowledge_changes_request_serialization() {
1009 use crate::runbeam_api::resources::AcknowledgeChangesRequest;
1010
1011 let request = AcknowledgeChangesRequest {
1012 change_ids: vec![
1013 "change-1".to_string(),
1014 "change-2".to_string(),
1015 "change-3".to_string(),
1016 ],
1017 };
1018
1019 let json = serde_json::to_string(&request).unwrap();
1020 assert!(json.contains("\"change_ids\""));
1021 assert!(json.contains("\"change-1\""));
1022 assert!(json.contains("\"change-2\""));
1023 assert!(json.contains("\"change-3\""));
1024
1025 let deserialized: AcknowledgeChangesRequest = serde_json::from_str(&json).unwrap();
1027 assert_eq!(deserialized.change_ids.len(), 3);
1028 assert_eq!(deserialized.change_ids[0], "change-1");
1029 }
1030
1031 #[test]
1032 fn test_change_failed_request_serialization() {
1033 use crate::runbeam_api::resources::ChangeFailedRequest;
1034
1035 let request_with_details = ChangeFailedRequest {
1037 error: "Configuration parse error".to_string(),
1038 details: Some(vec![
1039 "Invalid JSON at line 42".to_string(),
1040 "Missing required field 'name'".to_string(),
1041 ]),
1042 };
1043
1044 let json = serde_json::to_string(&request_with_details).unwrap();
1045 assert!(json.contains("\"error\":\"Configuration parse error\""));
1046 assert!(json.contains("\"details\""));
1047 assert!(json.contains("Invalid JSON at line 42"));
1048
1049 let request_without_details = ChangeFailedRequest {
1051 error: "Unknown error".to_string(),
1052 details: None,
1053 };
1054
1055 let json = serde_json::to_string(&request_without_details).unwrap();
1056 assert!(json.contains("\"error\":\"Unknown error\""));
1057 assert!(!json.contains("\"details\"")); let deserialized: ChangeFailedRequest =
1061 serde_json::from_str(&serde_json::to_string(&request_with_details).unwrap()).unwrap();
1062 assert_eq!(deserialized.error, "Configuration parse error");
1063 assert!(deserialized.details.is_some());
1064 assert_eq!(deserialized.details.unwrap().len(), 2);
1065 }
1066
1067 #[test]
1068 fn test_base_url_response_serialization() {
1069 use crate::runbeam_api::resources::BaseUrlResponse;
1070
1071 let response = BaseUrlResponse {
1072 base_url: "https://api.runbeam.io".to_string(),
1073 };
1074
1075 let json = serde_json::to_string(&response).unwrap();
1076 assert!(json.contains("\"base_url\":\"https://api.runbeam.io\""));
1077
1078 let deserialized: BaseUrlResponse = serde_json::from_str(&json).unwrap();
1080 assert_eq!(deserialized.base_url, "https://api.runbeam.io");
1081 }
1082}