runbeam_sdk/runbeam_api/
client.rs

1use crate::runbeam_api::types::{
2    ApiError, AuthorizeResponse, ConfigChange, ConfigChangeAck, ConfigChangeDetail, RunbeamError,
3};
4use serde::Serialize;
5
6/// HTTP client for Runbeam Cloud API
7///
8/// This client handles all communication with the Runbeam Cloud control plane,
9/// including gateway authorization and future component loading.
10#[derive(Debug, Clone)]
11pub struct RunbeamClient {
12    /// Base URL for the Runbeam Cloud API (from JWT iss claim)
13    base_url: String,
14    /// HTTP client for making requests
15    client: reqwest::Client,
16}
17
18/// Request payload for gateway authorization
19#[derive(Debug, Serialize)]
20struct AuthorizeRequest {
21    /// JWT token from the user (will be sent in body per Laravel API spec)
22    token: String,
23    /// Gateway code (instance ID)
24    gateway_code: String,
25    /// Optional machine public key for secure communication
26    #[serde(skip_serializing_if = "Option::is_none")]
27    machine_public_key: Option<String>,
28    /// Optional metadata about the gateway (array of strings per v1.1 API spec)
29    #[serde(skip_serializing_if = "Option::is_none")]
30    metadata: Option<Vec<String>>,
31}
32
33impl RunbeamClient {
34    /// Create a new Runbeam Cloud API client
35    ///
36    /// # Arguments
37    ///
38    /// * `base_url` - The Runbeam Cloud API base URL (extracted from JWT iss claim)
39    ///
40    /// # Example
41    ///
42    /// ```no_run
43    /// use runbeam_sdk::RunbeamClient;
44    ///
45    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
46    /// ```
47    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        Self {
52            base_url,
53            client: reqwest::Client::new(),
54        }
55    }
56
57    /// Authorize a gateway and obtain a machine-scoped token
58    ///
59    /// This method exchanges a user authentication token (either JWT or Laravel Sanctum)
60    /// for a machine-scoped token that the gateway can use for autonomous API access.
61    /// The machine token has a 30-day expiry (configured server-side).
62    ///
63    /// # Authentication
64    ///
65    /// This method accepts both JWT tokens and Laravel Sanctum API tokens:
66    /// - **JWT tokens**: Validated locally with RS256 signature verification (legacy behavior)
67    /// - **Sanctum tokens**: Passed directly to server for validation (format: `{id}|{token}`)
68    ///
69    /// The token is passed to the Runbeam Cloud API in both the Authorization header
70    /// and request body, where final validation and authorization occurs.
71    ///
72    /// # Arguments
73    ///
74    /// * `user_token` - The user's JWT or Sanctum API token from CLI authentication
75    /// * `gateway_code` - The gateway instance ID
76    /// * `machine_public_key` - Optional public key for secure communication
77    /// * `metadata` - Optional metadata about the gateway (array of strings)
78    ///
79    /// # Returns
80    ///
81    /// Returns `Ok(AuthorizeResponse)` with machine token and gateway details,
82    /// or `Err(RunbeamError)` if authorization fails.
83    ///
84    /// # Example
85    ///
86    /// ```no_run
87    /// use runbeam_sdk::RunbeamClient;
88    ///
89    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
90    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
91    ///
92    /// // Using JWT token
93    /// let response = client.authorize_gateway(
94    ///     "eyJhbGci...",
95    ///     "gateway-123",
96    ///     None,
97    ///     None
98    /// ).await?;
99    ///
100    /// // Using Sanctum token
101    /// let response = client.authorize_gateway(
102    ///     "1|abc123def456...",
103    ///     "gateway-123",
104    ///     None,
105    ///     None
106    /// ).await?;
107    ///
108    /// println!("Machine token: {}", response.machine_token);
109    /// println!("Expires at: {}", response.expires_at);
110    /// # Ok(())
111    /// # }
112    /// ```
113    pub async fn authorize_gateway(
114        &self,
115        user_token: impl Into<String>,
116        gateway_code: impl Into<String>,
117        machine_public_key: Option<String>,
118        metadata: Option<Vec<String>>,
119    ) -> Result<AuthorizeResponse, RunbeamError> {
120        let user_token = user_token.into();
121        let gateway_code = gateway_code.into();
122
123        tracing::info!(
124            "Authorizing gateway with Runbeam Cloud: gateway_code={}",
125            gateway_code
126        );
127
128        // Construct the authorization endpoint URL
129        let url = format!("{}/api/harmony/authorize", self.base_url);
130
131        // Build request payload
132        let payload = AuthorizeRequest {
133            token: user_token.clone(),
134            gateway_code: gateway_code.clone(),
135            machine_public_key,
136            metadata,
137        };
138
139        tracing::debug!("Sending authorization request to: {}", url);
140
141        // Make the request
142        let response = self
143            .client
144            .post(&url)
145            .header("Authorization", format!("Bearer {}", user_token))
146            .header("Content-Type", "application/json")
147            .json(&payload)
148            .send()
149            .await
150            .map_err(|e| {
151                tracing::error!("Failed to send authorization request: {}", e);
152                ApiError::from(e)
153            })?;
154
155        let status = response.status();
156        tracing::debug!("Received response with status: {}", status);
157
158        // Handle error responses
159        if !status.is_success() {
160            let error_body = response
161                .text()
162                .await
163                .unwrap_or_else(|_| "Unknown error".to_string());
164
165            tracing::error!(
166                "Authorization failed: HTTP {} - {}",
167                status.as_u16(),
168                error_body
169            );
170
171            return Err(RunbeamError::Api(ApiError::Http {
172                status: status.as_u16(),
173                message: error_body,
174            }));
175        }
176
177        // Parse successful response
178        let auth_response: AuthorizeResponse = response.json().await.map_err(|e| {
179            tracing::error!("Failed to parse authorization response: {}", e);
180            ApiError::Parse(format!("Failed to parse response JSON: {}", e))
181        })?;
182
183        tracing::info!(
184            "Gateway authorized successfully: gateway_id={}, expires_at={}",
185            auth_response.gateway.id,
186            auth_response.expires_at
187        );
188
189        tracing::debug!(
190            "Machine token length: {}",
191            auth_response.machine_token.len()
192        );
193        tracing::debug!("Gateway abilities: {:?}", auth_response.abilities);
194
195        Ok(auth_response)
196    }
197
198    /// Get the base URL for this client
199    pub fn base_url(&self) -> &str {
200        &self.base_url
201    }
202
203    /// List pending config changes for a gateway
204    ///
205    /// # Arguments
206    ///
207    /// * `gateway_token` - Machine token for the gateway
208    pub async fn list_config_changes(
209        &self,
210        gateway_token: impl Into<String>,
211    ) -> Result<Vec<ConfigChange>, RunbeamError> {
212        let url = format!("{}/api/harmony/config-changes", self.base_url);
213
214        let response = self
215            .client
216            .get(&url)
217            .header("Authorization", format!("Bearer {}", gateway_token.into()))
218            .send()
219            .await
220            .map_err(ApiError::from)?;
221
222        if !response.status().is_success() {
223            let status = response.status();
224            let error_body = response
225                .text()
226                .await
227                .unwrap_or_else(|_| "Unknown error".to_string());
228            return Err(RunbeamError::Api(ApiError::Http {
229                status: status.as_u16(),
230                message: error_body,
231            }));
232        }
233
234        response.json().await.map_err(|e| {
235            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
236        })
237    }
238
239    /// Get detailed config change content
240    ///
241    /// # Arguments
242    ///
243    /// * `gateway_token` - Machine token for the gateway
244    /// * `change_id` - ID of the config change
245    pub async fn get_config_change(
246        &self,
247        gateway_token: impl Into<String>,
248        change_id: impl Into<String>,
249    ) -> Result<ConfigChangeDetail, RunbeamError> {
250        let url = format!(
251            "{}/api/harmony/config-changes/{}",
252            self.base_url,
253            change_id.into()
254        );
255
256        let response = self
257            .client
258            .get(&url)
259            .header("Authorization", format!("Bearer {}", gateway_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            return Err(RunbeamError::Api(ApiError::Http {
271                status: status.as_u16(),
272                message: error_body,
273            }));
274        }
275
276        response.json().await.map_err(|e| {
277            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
278        })
279    }
280
281    /// Acknowledge receipt of a config change
282    ///
283    /// # Arguments
284    ///
285    /// * `gateway_token` - Machine token for the gateway
286    /// * `change_id` - ID of the config change
287    pub async fn acknowledge_config_change(
288        &self,
289        gateway_token: impl Into<String>,
290        change_id: impl Into<String>,
291    ) -> Result<ConfigChangeAck, RunbeamError> {
292        let url = format!(
293            "{}/api/harmony/config-changes/{}/acknowledge",
294            self.base_url,
295            change_id.into()
296        );
297
298        let response = self
299            .client
300            .post(&url)
301            .header("Authorization", format!("Bearer {}", gateway_token.into()))
302            .send()
303            .await
304            .map_err(ApiError::from)?;
305
306        if !response.status().is_success() {
307            let status = response.status();
308            let error_body = response
309                .text()
310                .await
311                .unwrap_or_else(|_| "Unknown error".to_string());
312            return Err(RunbeamError::Api(ApiError::Http {
313                status: status.as_u16(),
314                message: error_body,
315            }));
316        }
317
318        response.json().await.map_err(|e| {
319            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
320        })
321    }
322
323    /// Report successful application of a config change
324    ///
325    /// # Arguments
326    ///
327    /// * `gateway_token` - Machine token for the gateway
328    /// * `change_id` - ID of the config change
329    pub async fn report_config_applied(
330        &self,
331        gateway_token: impl Into<String>,
332        change_id: impl Into<String>,
333    ) -> Result<ConfigChangeAck, RunbeamError> {
334        let url = format!(
335            "{}/api/harmony/config-changes/{}/applied",
336            self.base_url,
337            change_id.into()
338        );
339
340        let response = self
341            .client
342            .post(&url)
343            .header("Authorization", format!("Bearer {}", gateway_token.into()))
344            .send()
345            .await
346            .map_err(ApiError::from)?;
347
348        if !response.status().is_success() {
349            let status = response.status();
350            let error_body = response
351                .text()
352                .await
353                .unwrap_or_else(|_| "Unknown error".to_string());
354            return Err(RunbeamError::Api(ApiError::Http {
355                status: status.as_u16(),
356                message: error_body,
357            }));
358        }
359
360        response.json().await.map_err(|e| {
361            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
362        })
363    }
364
365    /// Report failed application of a config change
366    ///
367    /// # Arguments
368    ///
369    /// * `gateway_token` - Machine token for the gateway
370    /// * `change_id` - ID of the config change
371    /// * `error` - Error message describing the failure
372    pub async fn report_config_failed(
373        &self,
374        gateway_token: impl Into<String>,
375        change_id: impl Into<String>,
376        error: impl Into<String>,
377    ) -> Result<ConfigChangeAck, RunbeamError> {
378        let url = format!(
379            "{}/api/harmony/config-changes/{}/failed",
380            self.base_url,
381            change_id.into()
382        );
383
384        #[derive(Serialize)]
385        struct FailurePayload {
386            error: String,
387        }
388
389        let payload = FailurePayload {
390            error: error.into(),
391        };
392
393        let response = self
394            .client
395            .post(&url)
396            .header("Authorization", format!("Bearer {}", gateway_token.into()))
397            .json(&payload)
398            .send()
399            .await
400            .map_err(ApiError::from)?;
401
402        if !response.status().is_success() {
403            let status = response.status();
404            let error_body = response
405                .text()
406                .await
407                .unwrap_or_else(|_| "Unknown error".to_string());
408            return Err(RunbeamError::Api(ApiError::Http {
409                status: status.as_u16(),
410                message: error_body,
411            }));
412        }
413
414        response.json().await.map_err(|e| {
415            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
416        })
417    }
418
419    /// List all gateways for the authenticated team
420    ///
421    /// Returns a paginated list of gateways.
422    ///
423    /// # Authentication
424    ///
425    /// Accepts either JWT tokens or Laravel Sanctum API tokens. The token is passed
426    /// to the server for validation without local verification.
427    ///
428    /// # Arguments
429    ///
430    /// * `token` - JWT or Sanctum API token for authentication
431    pub async fn list_gateways(
432        &self,
433        token: impl Into<String>,
434    ) -> Result<
435        crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Gateway>,
436        RunbeamError,
437    > {
438        let url = format!("{}/api/gateways", self.base_url);
439
440        let response = self
441            .client
442            .get(&url)
443            .header("Authorization", format!("Bearer {}", token.into()))
444            .send()
445            .await
446            .map_err(ApiError::from)?;
447
448        if !response.status().is_success() {
449            let status = response.status();
450            let error_body = response
451                .text()
452                .await
453                .unwrap_or_else(|_| "Unknown error".to_string());
454            return Err(RunbeamError::Api(ApiError::Http {
455                status: status.as_u16(),
456                message: error_body,
457            }));
458        }
459
460        response.json().await.map_err(|e| {
461            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
462        })
463    }
464
465    /// Get a specific gateway by ID or code
466    ///
467    /// # Authentication
468    ///
469    /// Accepts JWT tokens, Sanctum API tokens, or machine tokens. The token is passed
470    /// to the server for validation without local verification.
471    ///
472    /// # Arguments
473    ///
474    /// * `token` - JWT, Sanctum API token, or machine token for authentication
475    /// * `gateway_id` - The gateway ID or code
476    pub async fn get_gateway(
477        &self,
478        token: impl Into<String>,
479        gateway_id: impl Into<String>,
480    ) -> Result<
481        crate::runbeam_api::resources::ResourceResponse<crate::runbeam_api::resources::Gateway>,
482        RunbeamError,
483    > {
484        let url = format!("{}/api/gateways/{}", self.base_url, gateway_id.into());
485
486        let response = self
487            .client
488            .get(&url)
489            .header("Authorization", format!("Bearer {}", token.into()))
490            .send()
491            .await
492            .map_err(ApiError::from)?;
493
494        if !response.status().is_success() {
495            let status = response.status();
496            let error_body = response
497                .text()
498                .await
499                .unwrap_or_else(|_| "Unknown error".to_string());
500            return Err(RunbeamError::Api(ApiError::Http {
501                status: status.as_u16(),
502                message: error_body,
503            }));
504        }
505
506        response.json().await.map_err(|e| {
507            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
508        })
509    }
510
511    /// List all services for the authenticated team
512    ///
513    /// Returns a paginated list of services across all gateways.
514    ///
515    /// # Authentication
516    ///
517    /// Accepts either JWT tokens or Laravel Sanctum API tokens. The token is passed
518    /// to the server for validation without local verification.
519    ///
520    /// # Arguments
521    ///
522    /// * `token` - JWT or Sanctum API token for authentication
523    pub async fn list_services(
524        &self,
525        token: impl Into<String>,
526    ) -> Result<
527        crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Service>,
528        RunbeamError,
529    > {
530        let url = format!("{}/api/services", self.base_url);
531
532        let response = self
533            .client
534            .get(&url)
535            .header("Authorization", format!("Bearer {}", token.into()))
536            .send()
537            .await
538            .map_err(ApiError::from)?;
539
540        if !response.status().is_success() {
541            let status = response.status();
542            let error_body = response
543                .text()
544                .await
545                .unwrap_or_else(|_| "Unknown error".to_string());
546            return Err(RunbeamError::Api(ApiError::Http {
547                status: status.as_u16(),
548                message: error_body,
549            }));
550        }
551
552        response.json().await.map_err(|e| {
553            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
554        })
555    }
556
557    /// Get a specific service by ID
558    ///
559    /// # Authentication
560    ///
561    /// Accepts JWT tokens, Sanctum API tokens, or machine tokens. The token is passed
562    /// to the server for validation without local verification.
563    ///
564    /// # Arguments
565    ///
566    /// * `token` - JWT, Sanctum API token, or machine token for authentication
567    /// * `service_id` - The service ID
568    pub async fn get_service(
569        &self,
570        token: impl Into<String>,
571        service_id: impl Into<String>,
572    ) -> Result<
573        crate::runbeam_api::resources::ResourceResponse<crate::runbeam_api::resources::Service>,
574        RunbeamError,
575    > {
576        let url = format!("{}/api/services/{}", self.base_url, service_id.into());
577
578        let response = self
579            .client
580            .get(&url)
581            .header("Authorization", format!("Bearer {}", token.into()))
582            .send()
583            .await
584            .map_err(ApiError::from)?;
585
586        if !response.status().is_success() {
587            let status = response.status();
588            let error_body = response
589                .text()
590                .await
591                .unwrap_or_else(|_| "Unknown error".to_string());
592            return Err(RunbeamError::Api(ApiError::Http {
593                status: status.as_u16(),
594                message: error_body,
595            }));
596        }
597
598        response.json().await.map_err(|e| {
599            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
600        })
601    }
602
603    /// List all endpoints for the authenticated team
604    ///
605    /// # Authentication
606    ///
607    /// Accepts either JWT tokens or Laravel Sanctum API tokens. The token is passed
608    /// to the server for validation without local verification.
609    ///
610    /// # Arguments
611    ///
612    /// * `token` - JWT or Sanctum API token for authentication
613    pub async fn list_endpoints(
614        &self,
615        token: impl Into<String>,
616    ) -> Result<
617        crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Endpoint>,
618        RunbeamError,
619    > {
620        let url = format!("{}/api/endpoints", self.base_url);
621
622        let response = self
623            .client
624            .get(&url)
625            .header("Authorization", format!("Bearer {}", token.into()))
626            .send()
627            .await
628            .map_err(ApiError::from)?;
629
630        if !response.status().is_success() {
631            let status = response.status();
632            let error_body = response
633                .text()
634                .await
635                .unwrap_or_else(|_| "Unknown error".to_string());
636            return Err(RunbeamError::Api(ApiError::Http {
637                status: status.as_u16(),
638                message: error_body,
639            }));
640        }
641
642        response.json().await.map_err(|e| {
643            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
644        })
645    }
646
647    /// List all backends for the authenticated team
648    ///
649    /// # Authentication
650    ///
651    /// Accepts either JWT tokens or Laravel Sanctum API tokens. The token is passed
652    /// to the server for validation without local verification.
653    ///
654    /// # Arguments
655    ///
656    /// * `token` - JWT or Sanctum API token for authentication
657    pub async fn list_backends(
658        &self,
659        token: impl Into<String>,
660    ) -> Result<
661        crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Backend>,
662        RunbeamError,
663    > {
664        let url = format!("{}/api/backends", self.base_url);
665
666        let response = self
667            .client
668            .get(&url)
669            .header("Authorization", format!("Bearer {}", token.into()))
670            .send()
671            .await
672            .map_err(ApiError::from)?;
673
674        if !response.status().is_success() {
675            let status = response.status();
676            let error_body = response
677                .text()
678                .await
679                .unwrap_or_else(|_| "Unknown error".to_string());
680            return Err(RunbeamError::Api(ApiError::Http {
681                status: status.as_u16(),
682                message: error_body,
683            }));
684        }
685
686        response.json().await.map_err(|e| {
687            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
688        })
689    }
690
691    /// List all pipelines for the authenticated team
692    ///
693    /// # Authentication
694    ///
695    /// Accepts either JWT tokens or Laravel Sanctum API tokens. The token is passed
696    /// to the server for validation without local verification.
697    ///
698    /// # Arguments
699    ///
700    /// * `token` - JWT or Sanctum API token for authentication
701    pub async fn list_pipelines(
702        &self,
703        token: impl Into<String>,
704    ) -> Result<
705        crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Pipeline>,
706        RunbeamError,
707    > {
708        let url = format!("{}/api/pipelines", self.base_url);
709
710        let response = self
711            .client
712            .get(&url)
713            .header("Authorization", format!("Bearer {}", token.into()))
714            .send()
715            .await
716            .map_err(ApiError::from)?;
717
718        if !response.status().is_success() {
719            let status = response.status();
720            let error_body = response
721                .text()
722                .await
723                .unwrap_or_else(|_| "Unknown error".to_string());
724            return Err(RunbeamError::Api(ApiError::Http {
725                status: status.as_u16(),
726                message: error_body,
727            }));
728        }
729
730        response.json().await.map_err(|e| {
731            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
732        })
733    }
734
735    // ========== Change Management API Methods (v1.2) ==========
736
737    /// Get the base URL for the changes API
738    ///
739    /// Service discovery endpoint that returns the base URL for the changes API.
740    /// Harmony Proxy instances can call this to discover the API location dynamically.
741    ///
742    /// # Authentication
743    ///
744    /// Accepts JWT tokens, Sanctum API tokens, or machine tokens.
745    ///
746    /// # Arguments
747    ///
748    /// * `token` - Authentication token (JWT, Sanctum, or machine token)
749    ///
750    /// # Example
751    ///
752    /// ```no_run
753    /// use runbeam_sdk::RunbeamClient;
754    ///
755    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
756    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
757    /// let response = client.get_base_url("machine_token_abc123").await?;
758    /// println!("Changes API base URL: {}", response.base_url);
759    /// # Ok(())
760    /// # }
761    /// ```
762    pub async fn get_base_url(
763        &self,
764        token: impl Into<String>,
765    ) -> Result<crate::runbeam_api::resources::BaseUrlResponse, RunbeamError> {
766        let url = format!("{}/gateway/base-url", self.base_url);
767
768        tracing::debug!("Getting base URL from: {}", url);
769
770        let response = self
771            .client
772            .get(&url)
773            .header("Authorization", format!("Bearer {}", token.into()))
774            .send()
775            .await
776            .map_err(ApiError::from)?;
777
778        if !response.status().is_success() {
779            let status = response.status();
780            let error_body = response
781                .text()
782                .await
783                .unwrap_or_else(|_| "Unknown error".to_string());
784            tracing::error!("Failed to get base URL: HTTP {} - {}", status, error_body);
785            return Err(RunbeamError::Api(ApiError::Http {
786                status: status.as_u16(),
787                message: error_body,
788            }));
789        }
790
791        response.json().await.map_err(|e| {
792            tracing::error!("Failed to parse base URL response: {}", e);
793            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
794        })
795    }
796
797    /// List pending configuration changes for the authenticated gateway
798    ///
799    /// Retrieve queued configuration changes that are ready to be applied.
800    /// Gateways typically poll this endpoint every 30 seconds to check for updates.
801    ///
802    /// # Authentication
803    ///
804    /// Accepts JWT tokens, Sanctum API tokens, or machine tokens.
805    ///
806    /// # Arguments
807    ///
808    /// * `token` - Authentication token (JWT, Sanctum, or machine token)
809    ///
810    /// # Example
811    ///
812    /// ```no_run
813    /// use runbeam_sdk::RunbeamClient;
814    ///
815    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
816    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
817    /// let changes = client.list_changes("machine_token_abc123").await?;
818    /// println!("Found {} pending changes", changes.data.len());
819    /// # Ok(())
820    /// # }
821    /// ```
822    pub async fn list_changes(
823        &self,
824        token: impl Into<String>,
825    ) -> Result<
826        crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Change>,
827        RunbeamError,
828    > {
829        let url = format!("{}/gateway/changes", self.base_url);
830
831        tracing::debug!("Listing changes from: {}", url);
832
833        let response = self
834            .client
835            .get(&url)
836            .header("Authorization", format!("Bearer {}", token.into()))
837            .send()
838            .await
839            .map_err(ApiError::from)?;
840
841        if !response.status().is_success() {
842            let status = response.status();
843            let error_body = response
844                .text()
845                .await
846                .unwrap_or_else(|_| "Unknown error".to_string());
847            tracing::error!("Failed to list changes: HTTP {} - {}", status, error_body);
848            return Err(RunbeamError::Api(ApiError::Http {
849                status: status.as_u16(),
850                message: error_body,
851            }));
852        }
853
854        response.json().await.map_err(|e| {
855            tracing::error!("Failed to parse changes response: {}", e);
856            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
857        })
858    }
859
860    /// Get details of a specific configuration change
861    ///
862    /// Retrieve detailed information about a specific change by its ID.
863    ///
864    /// # Authentication
865    ///
866    /// Accepts JWT tokens, Sanctum API tokens, or machine tokens.
867    ///
868    /// # Arguments
869    ///
870    /// * `token` - Authentication token (JWT, Sanctum, or machine token)
871    /// * `change_id` - The change ID to retrieve
872    ///
873    /// # Example
874    ///
875    /// ```no_run
876    /// use runbeam_sdk::RunbeamClient;
877    ///
878    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
879    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
880    /// let change = client.get_change("machine_token_abc123", "change-123").await?;
881    /// println!("Change status: {}", change.data.status);
882    /// # Ok(())
883    /// # }
884    /// ```
885    pub async fn get_change(
886        &self,
887        token: impl Into<String>,
888        change_id: impl Into<String>,
889    ) -> Result<
890        crate::runbeam_api::resources::ResourceResponse<crate::runbeam_api::resources::Change>,
891        RunbeamError,
892    > {
893        let change_id = change_id.into();
894        let url = format!("{}/gateway/changes/{}", self.base_url, change_id);
895
896        tracing::debug!("Getting change {} from: {}", change_id, url);
897
898        let response = self
899            .client
900            .get(&url)
901            .header("Authorization", format!("Bearer {}", token.into()))
902            .send()
903            .await
904            .map_err(ApiError::from)?;
905
906        if !response.status().is_success() {
907            let status = response.status();
908            let error_body = response
909                .text()
910                .await
911                .unwrap_or_else(|_| "Unknown error".to_string());
912            tracing::error!("Failed to get change: HTTP {} - {}", status, error_body);
913            return Err(RunbeamError::Api(ApiError::Http {
914                status: status.as_u16(),
915                message: error_body,
916            }));
917        }
918
919        response.json().await.map_err(|e| {
920            tracing::error!("Failed to parse change response: {}", e);
921            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
922        })
923    }
924
925    /// Acknowledge receipt of multiple configuration changes
926    ///
927    /// Bulk acknowledge that changes have been received. Gateways should call this
928    /// immediately after retrieving changes to update their status from "pending"
929    /// to "acknowledged".
930    ///
931    /// # Authentication
932    ///
933    /// Accepts JWT tokens, Sanctum API tokens, or machine tokens.
934    ///
935    /// # Arguments
936    ///
937    /// * `token` - Authentication token (JWT, Sanctum, or machine token)
938    /// * `change_ids` - Vector of change IDs to acknowledge
939    ///
940    /// # Example
941    ///
942    /// ```no_run
943    /// use runbeam_sdk::RunbeamClient;
944    ///
945    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
946    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
947    /// let change_ids = vec!["change-1".to_string(), "change-2".to_string()];
948    /// client.acknowledge_changes("machine_token_abc123", change_ids).await?;
949    /// # Ok(())
950    /// # }
951    /// ```
952    pub async fn acknowledge_changes(
953        &self,
954        token: impl Into<String>,
955        change_ids: Vec<String>,
956    ) -> Result<serde_json::Value, RunbeamError> {
957        let url = format!("{}/gateway/changes/acknowledge", self.base_url);
958
959        tracing::info!("Acknowledging {} changes", change_ids.len());
960        tracing::debug!("Change IDs: {:?}", change_ids);
961
962        let payload = crate::runbeam_api::resources::AcknowledgeChangesRequest { change_ids };
963
964        let response = self
965            .client
966            .post(&url)
967            .header("Authorization", format!("Bearer {}", token.into()))
968            .header("Content-Type", "application/json")
969            .json(&payload)
970            .send()
971            .await
972            .map_err(ApiError::from)?;
973
974        if !response.status().is_success() {
975            let status = response.status();
976            let error_body = response
977                .text()
978                .await
979                .unwrap_or_else(|_| "Unknown error".to_string());
980            tracing::error!(
981                "Failed to acknowledge changes: HTTP {} - {}",
982                status,
983                error_body
984            );
985            return Err(RunbeamError::Api(ApiError::Http {
986                status: status.as_u16(),
987                message: error_body,
988            }));
989        }
990
991        response.json().await.map_err(|e| {
992            tracing::error!("Failed to parse acknowledge response: {}", e);
993            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
994        })
995    }
996
997    /// Mark a configuration change as successfully applied
998    ///
999    /// Report that a change has been successfully applied to the gateway configuration.
1000    /// This updates the change status to "applied".
1001    ///
1002    /// # Authentication
1003    ///
1004    /// Accepts JWT tokens, Sanctum API tokens, or machine tokens.
1005    ///
1006    /// # Arguments
1007    ///
1008    /// * `token` - Authentication token (JWT, Sanctum, or machine token)
1009    /// * `change_id` - The change ID that was applied
1010    ///
1011    /// # Example
1012    ///
1013    /// ```no_run
1014    /// use runbeam_sdk::RunbeamClient;
1015    ///
1016    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1017    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
1018    /// client.mark_change_applied("machine_token_abc123", "change-123").await?;
1019    /// # Ok(())
1020    /// # }
1021    /// ```
1022    pub async fn mark_change_applied(
1023        &self,
1024        token: impl Into<String>,
1025        change_id: impl Into<String>,
1026    ) -> Result<serde_json::Value, RunbeamError> {
1027        let change_id = change_id.into();
1028        let url = format!("{}/gateway/changes/{}/applied", self.base_url, change_id);
1029
1030        tracing::info!("Marking change {} as applied", change_id);
1031
1032        let response = self
1033            .client
1034            .post(&url)
1035            .header("Authorization", format!("Bearer {}", token.into()))
1036            .send()
1037            .await
1038            .map_err(ApiError::from)?;
1039
1040        if !response.status().is_success() {
1041            let status = response.status();
1042            let error_body = response
1043                .text()
1044                .await
1045                .unwrap_or_else(|_| "Unknown error".to_string());
1046            tracing::error!(
1047                "Failed to mark change as applied: HTTP {} - {}",
1048                status,
1049                error_body
1050            );
1051            return Err(RunbeamError::Api(ApiError::Http {
1052                status: status.as_u16(),
1053                message: error_body,
1054            }));
1055        }
1056
1057        response.json().await.map_err(|e| {
1058            tracing::error!("Failed to parse applied response: {}", e);
1059            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
1060        })
1061    }
1062
1063    /// Mark a configuration change as failed with error details
1064    ///
1065    /// Report that a change failed to apply, including error details for troubleshooting.
1066    /// This updates the change status to "failed" and stores the error information.
1067    ///
1068    /// # Authentication
1069    ///
1070    /// Accepts JWT tokens, Sanctum API tokens, or machine tokens.
1071    ///
1072    /// # Arguments
1073    ///
1074    /// * `token` - Authentication token (JWT, Sanctum, or machine token)
1075    /// * `change_id` - The change ID that failed
1076    /// * `error` - Error message describing what went wrong
1077    /// * `details` - Optional additional error details
1078    ///
1079    /// # Example
1080    ///
1081    /// ```no_run
1082    /// use runbeam_sdk::RunbeamClient;
1083    ///
1084    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1085    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
1086    /// client.mark_change_failed(
1087    ///     "machine_token_abc123",
1088    ///     "change-123",
1089    ///     "Failed to parse configuration".to_string(),
1090    ///     Some(vec!["Invalid JSON syntax at line 42".to_string()])
1091    /// ).await?;
1092    /// # Ok(())
1093    /// # }
1094    /// ```
1095    pub async fn mark_change_failed(
1096        &self,
1097        token: impl Into<String>,
1098        change_id: impl Into<String>,
1099        error: String,
1100        details: Option<Vec<String>>,
1101    ) -> Result<serde_json::Value, RunbeamError> {
1102        let change_id = change_id.into();
1103        let url = format!("{}/gateway/changes/{}/failed", self.base_url, change_id);
1104
1105        tracing::warn!("Marking change {} as failed: {}", change_id, error);
1106        if let Some(ref details) = details {
1107            tracing::debug!("Failure details: {:?}", details);
1108        }
1109
1110        let payload = crate::runbeam_api::resources::ChangeFailedRequest { error, details };
1111
1112        let response = self
1113            .client
1114            .post(&url)
1115            .header("Authorization", format!("Bearer {}", token.into()))
1116            .header("Content-Type", "application/json")
1117            .json(&payload)
1118            .send()
1119            .await
1120            .map_err(ApiError::from)?;
1121
1122        if !response.status().is_success() {
1123            let status = response.status();
1124            let error_body = response
1125                .text()
1126                .await
1127                .unwrap_or_else(|_| "Unknown error".to_string());
1128            tracing::error!(
1129                "Failed to mark change as failed: HTTP {} - {}",
1130                status,
1131                error_body
1132            );
1133            return Err(RunbeamError::Api(ApiError::Http {
1134                status: status.as_u16(),
1135                message: error_body,
1136            }));
1137        }
1138
1139        response.json().await.map_err(|e| {
1140            tracing::error!("Failed to parse failed response: {}", e);
1141            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
1142        })
1143    }
1144}
1145
1146#[cfg(test)]
1147mod tests {
1148    use super::*;
1149
1150    #[test]
1151    fn test_client_creation() {
1152        let client = RunbeamClient::new("http://example.com");
1153        assert_eq!(client.base_url(), "http://example.com");
1154    }
1155
1156    #[test]
1157    fn test_client_creation_with_string() {
1158        let base_url = String::from("http://example.com");
1159        let client = RunbeamClient::new(base_url);
1160        assert_eq!(client.base_url(), "http://example.com");
1161    }
1162
1163    #[test]
1164    fn test_authorize_request_serialization() {
1165        let request = AuthorizeRequest {
1166            token: "test_token".to_string(),
1167            gateway_code: "gw123".to_string(),
1168            machine_public_key: Some("pubkey123".to_string()),
1169            metadata: None,
1170        };
1171
1172        let json = serde_json::to_string(&request).unwrap();
1173        assert!(json.contains("\"token\":\"test_token\""));
1174        assert!(json.contains("\"gateway_code\":\"gw123\""));
1175        assert!(json.contains("\"machine_public_key\":\"pubkey123\""));
1176    }
1177
1178    #[test]
1179    fn test_authorize_request_serialization_without_optional_fields() {
1180        let request = AuthorizeRequest {
1181            token: "test_token".to_string(),
1182            gateway_code: "gw123".to_string(),
1183            machine_public_key: None,
1184            metadata: None,
1185        };
1186
1187        let json = serde_json::to_string(&request).unwrap();
1188        assert!(json.contains("\"token\":\"test_token\""));
1189        assert!(json.contains("\"gateway_code\":\"gw123\""));
1190        // Should not contain the optional fields
1191        assert!(!json.contains("machine_public_key"));
1192        assert!(!json.contains("metadata"));
1193    }
1194
1195    #[test]
1196    fn test_change_serialization() {
1197        use crate::runbeam_api::resources::Change;
1198
1199        let change = Change {
1200            id: "change-123".to_string(),
1201            resource_type: "change".to_string(),
1202            gateway_id: "gateway-456".to_string(),
1203            status: "pending".to_string(),
1204            operation: "create".to_string(),
1205            change_resource_type: "endpoint".to_string(),
1206            resource_id: "endpoint-789".to_string(),
1207            payload: serde_json::json!({"name": "test-endpoint"}),
1208            error: None,
1209            created_at: "2024-01-01T00:00:00Z".to_string(),
1210            updated_at: "2024-01-01T00:00:00Z".to_string(),
1211        };
1212
1213        let json = serde_json::to_string(&change).unwrap();
1214        assert!(json.contains("\"id\":\"change-123\""));
1215        assert!(json.contains("\"gateway_id\":\"gateway-456\""));
1216        assert!(json.contains("\"status\":\"pending\""));
1217        assert!(json.contains("\"operation\":\"create\""));
1218
1219        // Test deserialization
1220        let deserialized: Change = serde_json::from_str(&json).unwrap();
1221        assert_eq!(deserialized.id, "change-123");
1222        assert_eq!(deserialized.status, "pending");
1223    }
1224
1225    #[test]
1226    fn test_acknowledge_changes_request_serialization() {
1227        use crate::runbeam_api::resources::AcknowledgeChangesRequest;
1228
1229        let request = AcknowledgeChangesRequest {
1230            change_ids: vec![
1231                "change-1".to_string(),
1232                "change-2".to_string(),
1233                "change-3".to_string(),
1234            ],
1235        };
1236
1237        let json = serde_json::to_string(&request).unwrap();
1238        assert!(json.contains("\"change_ids\""));
1239        assert!(json.contains("\"change-1\""));
1240        assert!(json.contains("\"change-2\""));
1241        assert!(json.contains("\"change-3\""));
1242
1243        // Test deserialization
1244        let deserialized: AcknowledgeChangesRequest = serde_json::from_str(&json).unwrap();
1245        assert_eq!(deserialized.change_ids.len(), 3);
1246        assert_eq!(deserialized.change_ids[0], "change-1");
1247    }
1248
1249    #[test]
1250    fn test_change_failed_request_serialization() {
1251        use crate::runbeam_api::resources::ChangeFailedRequest;
1252
1253        // Test with details
1254        let request_with_details = ChangeFailedRequest {
1255            error: "Configuration parse error".to_string(),
1256            details: Some(vec![
1257                "Invalid JSON at line 42".to_string(),
1258                "Missing required field 'name'".to_string(),
1259            ]),
1260        };
1261
1262        let json = serde_json::to_string(&request_with_details).unwrap();
1263        assert!(json.contains("\"error\":\"Configuration parse error\""));
1264        assert!(json.contains("\"details\""));
1265        assert!(json.contains("Invalid JSON at line 42"));
1266
1267        // Test without details (should omit the field)
1268        let request_without_details = ChangeFailedRequest {
1269            error: "Unknown error".to_string(),
1270            details: None,
1271        };
1272
1273        let json = serde_json::to_string(&request_without_details).unwrap();
1274        assert!(json.contains("\"error\":\"Unknown error\""));
1275        assert!(!json.contains("\"details\"")); // Should be omitted due to skip_serializing_if
1276
1277        // Test deserialization
1278        let deserialized: ChangeFailedRequest =
1279            serde_json::from_str(&serde_json::to_string(&request_with_details).unwrap()).unwrap();
1280        assert_eq!(deserialized.error, "Configuration parse error");
1281        assert!(deserialized.details.is_some());
1282        assert_eq!(deserialized.details.unwrap().len(), 2);
1283    }
1284
1285    #[test]
1286    fn test_base_url_response_serialization() {
1287        use crate::runbeam_api::resources::BaseUrlResponse;
1288
1289        let response = BaseUrlResponse {
1290            base_url: "https://api.runbeam.io".to_string(),
1291        };
1292
1293        let json = serde_json::to_string(&response).unwrap();
1294        assert!(json.contains("\"base_url\":\"https://api.runbeam.io\""));
1295
1296        // Test deserialization
1297        let deserialized: BaseUrlResponse = serde_json::from_str(&json).unwrap();
1298        assert_eq!(deserialized.base_url, "https://api.runbeam.io");
1299    }
1300}