runbeam_sdk/runbeam_api/
client.rs

1use crate::runbeam_api::types::{ApiError, AuthorizeResponse, RunbeamError};
2use serde::Serialize;
3
4/// HTTP client for Runbeam Cloud API
5///
6/// This client handles all communication with the Runbeam Cloud control plane,
7/// including gateway authorization and future component loading.
8#[derive(Debug, Clone)]
9pub struct RunbeamClient {
10    /// Base URL for the Runbeam Cloud API (from JWT iss claim)
11    base_url: String,
12    /// HTTP client for making requests
13    client: reqwest::Client,
14}
15
16/// Request payload for gateway authorization
17#[derive(Debug, Serialize)]
18struct AuthorizeRequest {
19    /// JWT token from the user (will be sent in body per Laravel API spec)
20    token: String,
21    /// Gateway code (instance ID)
22    gateway_code: String,
23    /// Optional machine public key for secure communication
24    #[serde(skip_serializing_if = "Option::is_none")]
25    machine_public_key: Option<String>,
26    /// Optional metadata about the gateway (array of strings per v1.1 API spec)
27    #[serde(skip_serializing_if = "Option::is_none")]
28    metadata: Option<Vec<String>>,
29}
30
31impl RunbeamClient {
32    /// Create a new Runbeam Cloud API client
33    ///
34    /// # Arguments
35    ///
36    /// * `base_url` - The Runbeam Cloud API base URL (extracted from JWT iss claim)
37    ///
38    /// # Example
39    ///
40    /// ```no_run
41    /// use runbeam_sdk::RunbeamClient;
42    ///
43    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
44    /// ```
45    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    /// Authorize a gateway and obtain a machine-scoped token
56    ///
57    /// This method exchanges a user authentication token (either JWT or Laravel Sanctum)
58    /// for a machine-scoped token that the gateway can use for autonomous API access.
59    /// The machine token has a 30-day expiry (configured server-side).
60    ///
61    /// # Authentication
62    ///
63    /// This method accepts both JWT tokens and Laravel Sanctum API tokens:
64    /// - **JWT tokens**: Validated locally with RS256 signature verification (legacy behavior)
65    /// - **Sanctum tokens**: Passed directly to server for validation (format: `{id}|{token}`)
66    ///
67    /// The token is passed to the Runbeam Cloud API in both the Authorization header
68    /// and request body, where final validation and authorization occurs.
69    ///
70    /// # Arguments
71    ///
72    /// * `user_token` - The user's JWT or Sanctum API token from CLI authentication
73    /// * `gateway_code` - The gateway instance ID
74    /// * `machine_public_key` - Optional public key for secure communication
75    /// * `metadata` - Optional metadata about the gateway (array of strings)
76    ///
77    /// # Returns
78    ///
79    /// Returns `Ok(AuthorizeResponse)` with machine token and gateway details,
80    /// or `Err(RunbeamError)` if authorization fails.
81    ///
82    /// # Example
83    ///
84    /// ```no_run
85    /// use runbeam_sdk::RunbeamClient;
86    ///
87    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
88    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
89    ///
90    /// // Using JWT token
91    /// let response = client.authorize_gateway(
92    ///     "eyJhbGci...",
93    ///     "gateway-123",
94    ///     None,
95    ///     None
96    /// ).await?;
97    ///
98    /// // Using Sanctum token
99    /// let response = client.authorize_gateway(
100    ///     "1|abc123def456...",
101    ///     "gateway-123",
102    ///     None,
103    ///     None
104    /// ).await?;
105    ///
106    /// println!("Machine token: {}", response.machine_token);
107    /// println!("Expires at: {}", response.expires_at);
108    /// # Ok(())
109    /// # }
110    /// ```
111    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        // Construct the authorization endpoint URL
127        let url = format!("{}/api/harmony/authorize", self.base_url);
128
129        // Build request payload
130        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        // Make the request
140        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        // Handle error responses
157        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        // Parse successful response
176        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    /// Get the base URL for this client
197    pub fn base_url(&self) -> &str {
198        &self.base_url
199    }
200
201    /// List all gateways for the authenticated team
202    ///
203    /// Returns a paginated list of gateways.
204    ///
205    /// # Authentication
206    ///
207    /// Accepts either JWT tokens or Laravel Sanctum API tokens. The token is passed
208    /// to the server for validation without local verification.
209    ///
210    /// # Arguments
211    ///
212    /// * `token` - JWT or Sanctum API token for authentication
213    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    /// Get a specific gateway by ID or code
248    ///
249    /// # Authentication
250    ///
251    /// Accepts JWT tokens, Sanctum API tokens, or machine tokens. The token is passed
252    /// to the server for validation without local verification.
253    ///
254    /// # Arguments
255    ///
256    /// * `token` - JWT, Sanctum API token, or machine token for authentication
257    /// * `gateway_id` - The gateway ID or code
258    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    /// List all services for the authenticated team
294    ///
295    /// Returns a paginated list of services across all gateways.
296    ///
297    /// # Authentication
298    ///
299    /// Accepts either JWT tokens or Laravel Sanctum API tokens. The token is passed
300    /// to the server for validation without local verification.
301    ///
302    /// # Arguments
303    ///
304    /// * `token` - JWT or Sanctum API token for authentication
305    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    /// Get a specific service by ID
340    ///
341    /// # Authentication
342    ///
343    /// Accepts JWT tokens, Sanctum API tokens, or machine tokens. The token is passed
344    /// to the server for validation without local verification.
345    ///
346    /// # Arguments
347    ///
348    /// * `token` - JWT, Sanctum API token, or machine token for authentication
349    /// * `service_id` - The service ID
350    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    /// List all endpoints for the authenticated team
386    ///
387    /// # Authentication
388    ///
389    /// Accepts either JWT tokens or Laravel Sanctum API tokens. The token is passed
390    /// to the server for validation without local verification.
391    ///
392    /// # Arguments
393    ///
394    /// * `token` - JWT or Sanctum API token for authentication
395    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    /// List all backends for the authenticated team
430    ///
431    /// # Authentication
432    ///
433    /// Accepts either JWT tokens or Laravel Sanctum API tokens. The token is passed
434    /// to the server for validation without local verification.
435    ///
436    /// # Arguments
437    ///
438    /// * `token` - JWT or Sanctum API token for authentication
439    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    /// List all pipelines for the authenticated team
474    ///
475    /// # Authentication
476    ///
477    /// Accepts either JWT tokens or Laravel Sanctum API tokens. The token is passed
478    /// to the server for validation without local verification.
479    ///
480    /// # Arguments
481    ///
482    /// * `token` - JWT or Sanctum API token for authentication
483    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    // ========== Change Management API Methods (v1.2) ==========
518
519    /// Get the base URL for the changes API
520    ///
521    /// Service discovery endpoint that returns the base URL for the changes API.
522    /// Harmony Proxy instances can call this to discover the API location dynamically.
523    ///
524    /// # Authentication
525    ///
526    /// Accepts JWT tokens, Sanctum API tokens, or machine tokens.
527    ///
528    /// # Arguments
529    ///
530    /// * `token` - Authentication token (JWT, Sanctum, or machine token)
531    ///
532    /// # Example
533    ///
534    /// ```no_run
535    /// use runbeam_sdk::RunbeamClient;
536    ///
537    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
538    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
539    /// let response = client.get_base_url("machine_token_abc123").await?;
540    /// println!("Changes API base URL: {}", response.base_url);
541    /// # Ok(())
542    /// # }
543    /// ```
544    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    /// List pending configuration changes for the authenticated gateway
580    ///
581    /// Retrieve queued configuration changes that are ready to be applied.
582    /// Gateways typically poll this endpoint every 30 seconds to check for updates.
583    ///
584    /// # Authentication
585    ///
586    /// Accepts JWT tokens, Sanctum API tokens, or machine tokens.
587    ///
588    /// # Arguments
589    ///
590    /// * `token` - Authentication token (JWT, Sanctum, or machine token)
591    ///
592    /// # Example
593    ///
594    /// ```no_run
595    /// use runbeam_sdk::RunbeamClient;
596    ///
597    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
598    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
599    /// let changes = client.list_changes("machine_token_abc123").await?;
600    /// println!("Found {} pending changes", changes.data.len());
601    /// # Ok(())
602    /// # }
603    /// ```
604    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    /// Get details of a specific configuration change
643    ///
644    /// Retrieve detailed information about a specific change by its ID.
645    ///
646    /// # Authentication
647    ///
648    /// Accepts JWT tokens, Sanctum API tokens, or machine tokens.
649    ///
650    /// # Arguments
651    ///
652    /// * `token` - Authentication token (JWT, Sanctum, or machine token)
653    /// * `change_id` - The change ID to retrieve
654    ///
655    /// # Example
656    ///
657    /// ```no_run
658    /// use runbeam_sdk::RunbeamClient;
659    ///
660    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
661    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
662    /// let change = client.get_change("machine_token_abc123", "change-123").await?;
663    /// println!("Change status: {}", change.data.status);
664    /// # Ok(())
665    /// # }
666    /// ```
667    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    /// Acknowledge receipt of multiple configuration changes
708    ///
709    /// Bulk acknowledge that changes have been received. Gateways should call this
710    /// immediately after retrieving changes to update their status from "pending"
711    /// to "acknowledged".
712    ///
713    /// # Authentication
714    ///
715    /// Accepts JWT tokens, Sanctum API tokens, or machine tokens.
716    ///
717    /// # Arguments
718    ///
719    /// * `token` - Authentication token (JWT, Sanctum, or machine token)
720    /// * `change_ids` - Vector of change IDs to acknowledge
721    ///
722    /// # Example
723    ///
724    /// ```no_run
725    /// use runbeam_sdk::RunbeamClient;
726    ///
727    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
728    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
729    /// let change_ids = vec!["change-1".to_string(), "change-2".to_string()];
730    /// client.acknowledge_changes("machine_token_abc123", change_ids).await?;
731    /// # Ok(())
732    /// # }
733    /// ```
734    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    /// Mark a configuration change as successfully applied
780    ///
781    /// Report that a change has been successfully applied to the gateway configuration.
782    /// This updates the change status to "applied".
783    ///
784    /// # Authentication
785    ///
786    /// Accepts JWT tokens, Sanctum API tokens, or machine tokens.
787    ///
788    /// # Arguments
789    ///
790    /// * `token` - Authentication token (JWT, Sanctum, or machine token)
791    /// * `change_id` - The change ID that was applied
792    ///
793    /// # Example
794    ///
795    /// ```no_run
796    /// use runbeam_sdk::RunbeamClient;
797    ///
798    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
799    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
800    /// client.mark_change_applied("machine_token_abc123", "change-123").await?;
801    /// # Ok(())
802    /// # }
803    /// ```
804    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    /// Mark a configuration change as failed with error details
846    ///
847    /// Report that a change failed to apply, including error details for troubleshooting.
848    /// This updates the change status to "failed" and stores the error information.
849    ///
850    /// # Authentication
851    ///
852    /// Accepts JWT tokens, Sanctum API tokens, or machine tokens.
853    ///
854    /// # Arguments
855    ///
856    /// * `token` - Authentication token (JWT, Sanctum, or machine token)
857    /// * `change_id` - The change ID that failed
858    /// * `error` - Error message describing what went wrong
859    /// * `details` - Optional additional error details
860    ///
861    /// # Example
862    ///
863    /// ```no_run
864    /// use runbeam_sdk::RunbeamClient;
865    ///
866    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
867    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
868    /// client.mark_change_failed(
869    ///     "machine_token_abc123",
870    ///     "change-123",
871    ///     "Failed to parse configuration".to_string(),
872    ///     Some(vec!["Invalid JSON syntax at line 42".to_string()])
873    /// ).await?;
874    /// # Ok(())
875    /// # }
876    /// ```
877    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        // Should not contain the optional fields
973        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        // Test deserialization
1002        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        // Test deserialization
1026        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        // Test with details
1036        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        // Test without details (should omit the field)
1050        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\"")); // Should be omitted due to skip_serializing_if
1058
1059        // Test deserialization
1060        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        // Test deserialization
1079        let deserialized: BaseUrlResponse = serde_json::from_str(&json).unwrap();
1080        assert_eq!(deserialized.base_url, "https://api.runbeam.io");
1081    }
1082}