runbeam_sdk/runbeam_api/
client.rs

1use crate::runbeam_api::types::{
2    ApiError, AuthorizeResponse, RunbeamError, StoreConfigRequest, StoreConfigResponse,
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        // Check if we should accept invalid certificates (for local development)
52        let accept_invalid_certs = std::env::var("RUNBEAM_ACCEPT_INVALID_CERTS")
53            .ok()
54            .and_then(|v| v.parse::<bool>().ok())
55            .unwrap_or(false);
56
57        let client = if accept_invalid_certs {
58            tracing::warn!("⚠️  Accepting invalid SSL certificates (RUNBEAM_ACCEPT_INVALID_CERTS=true). This should only be used in development!");
59            reqwest::Client::builder()
60                .danger_accept_invalid_certs(true)
61                .build()
62                .expect("Failed to create HTTP client")
63        } else {
64            reqwest::Client::new()
65        };
66
67        Self {
68            base_url,
69            client,
70        }
71    }
72
73    /// Authorize a gateway and obtain a machine-scoped token
74    ///
75    /// This method exchanges a user authentication token (either JWT or Laravel Sanctum)
76    /// for a machine-scoped token that the gateway can use for autonomous API access.
77    /// The machine token has a 30-day expiry (configured server-side).
78    ///
79    /// # Authentication
80    ///
81    /// This method accepts both JWT tokens and Laravel Sanctum API tokens:
82    /// - **JWT tokens**: Validated locally with RS256 signature verification (legacy behavior)
83    /// - **Sanctum tokens**: Passed directly to server for validation (format: `{id}|{token}`)
84    ///
85    /// The token is passed to the Runbeam Cloud API in both the Authorization header
86    /// and request body, where final validation and authorization occurs.
87    ///
88    /// # Arguments
89    ///
90    /// * `user_token` - The user's JWT or Sanctum API token from CLI authentication
91    /// * `gateway_code` - The gateway instance ID
92    /// * `machine_public_key` - Optional public key for secure communication
93    /// * `metadata` - Optional metadata about the gateway (array of strings)
94    ///
95    /// # Returns
96    ///
97    /// Returns `Ok(AuthorizeResponse)` with machine token and gateway details,
98    /// or `Err(RunbeamError)` if authorization fails.
99    ///
100    /// # Example
101    ///
102    /// ```no_run
103    /// use runbeam_sdk::RunbeamClient;
104    ///
105    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
106    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
107    ///
108    /// // Using JWT token
109    /// let response = client.authorize_gateway(
110    ///     "eyJhbGci...",
111    ///     "gateway-123",
112    ///     None,
113    ///     None
114    /// ).await?;
115    ///
116    /// // Using Sanctum token
117    /// let response = client.authorize_gateway(
118    ///     "1|abc123def456...",
119    ///     "gateway-123",
120    ///     None,
121    ///     None
122    /// ).await?;
123    ///
124    /// println!("Machine token: {}", response.machine_token);
125    /// println!("Expires at: {}", response.expires_at);
126    /// # Ok(())
127    /// # }
128    /// ```
129    pub async fn authorize_gateway(
130        &self,
131        user_token: impl Into<String>,
132        gateway_code: impl Into<String>,
133        machine_public_key: Option<String>,
134        metadata: Option<Vec<String>>,
135    ) -> Result<AuthorizeResponse, RunbeamError> {
136        let user_token = user_token.into();
137        let gateway_code = gateway_code.into();
138
139        tracing::info!(
140            "Authorizing gateway with Runbeam Cloud: gateway_code={}",
141            gateway_code
142        );
143
144        // Construct the authorization endpoint URL
145        let url = format!("{}/harmony/authorize", self.base_url);
146
147        // Build request payload
148        let payload = AuthorizeRequest {
149            token: user_token.clone(),
150            gateway_code: gateway_code.clone(),
151            machine_public_key,
152            metadata,
153        };
154
155        tracing::debug!("Sending authorization request to: {}", url);
156
157        // Make the request
158        let response = self
159            .client
160            .post(&url)
161            .header("Authorization", format!("Bearer {}", user_token))
162            .header("Content-Type", "application/json")
163            .json(&payload)
164            .send()
165            .await
166            .map_err(|e| {
167                tracing::error!("Failed to send authorization request: {}", e);
168                ApiError::from(e)
169            })?;
170
171        let status = response.status();
172        tracing::debug!("Received response with status: {}", status);
173
174        // Handle error responses
175        if !status.is_success() {
176            let error_body = response
177                .text()
178                .await
179                .unwrap_or_else(|_| "Unknown error".to_string());
180
181            tracing::error!(
182                "Authorization failed: HTTP {} - {}",
183                status.as_u16(),
184                error_body
185            );
186
187            return Err(RunbeamError::Api(ApiError::Http {
188                status: status.as_u16(),
189                message: error_body,
190            }));
191        }
192
193        // Parse successful response
194        let auth_response: AuthorizeResponse = response.json().await.map_err(|e| {
195            tracing::error!("Failed to parse authorization response: {}", e);
196            ApiError::Parse(format!("Failed to parse response JSON: {}", e))
197        })?;
198
199        tracing::info!(
200            "Gateway authorized successfully: gateway_id={}, expires_at={}",
201            auth_response.gateway.id,
202            auth_response.expires_at
203        );
204
205        tracing::debug!(
206            "Machine token length: {}",
207            auth_response.machine_token.len()
208        );
209        tracing::debug!("Gateway abilities: {:?}", auth_response.abilities);
210
211        Ok(auth_response)
212    }
213
214    /// Get the base URL for this client
215    pub fn base_url(&self) -> &str {
216        &self.base_url
217    }
218
219    /// List all pending configuration changes (admin/user view)
220    ///
221    /// This endpoint lists ALL changes across the system and is intended for
222    /// administrative and user interfaces. Gateway instances should use
223    /// `list_changes_for_gateway` instead.
224    ///
225    /// # Authentication
226    ///
227    /// Accepts JWT tokens, Sanctum API tokens, or machine tokens.
228    ///
229    /// # Arguments
230    ///
231    /// * `token` - Authentication token (JWT, Sanctum, or machine token)
232    ///
233    /// # Example
234    ///
235    /// ```no_run
236    /// use runbeam_sdk::RunbeamClient;
237    ///
238    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
239    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
240    /// let changes = client.list_changes("user_jwt_or_sanctum_token").await?;
241    /// println!("Found {} changes across all gateways", changes.data.len());
242    /// # Ok(())
243    /// # }
244    /// ```
245    pub async fn list_changes(
246        &self,
247        token: impl Into<String>,
248    ) -> Result<
249        crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Change>,
250        RunbeamError,
251    > {
252        let url = format!("{}/harmony/changes", self.base_url);
253
254        tracing::debug!("Listing all changes from: {}", url);
255
256        let response = self
257            .client
258            .get(&url)
259            .header("Authorization", format!("Bearer {}", token.into()))
260            .send()
261            .await
262            .map_err(ApiError::from)?;
263
264        if !response.status().is_success() {
265            let status = response.status();
266            let error_body = response
267                .text()
268                .await
269                .unwrap_or_else(|_| "Unknown error".to_string());
270            tracing::error!("Failed to list changes: HTTP {} - {}", status, error_body);
271            return Err(RunbeamError::Api(ApiError::Http {
272                status: status.as_u16(),
273                message: error_body,
274            }));
275        }
276
277        response.json().await.map_err(|e| {
278            tracing::error!("Failed to parse changes response: {}", e);
279            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
280        })
281    }
282
283    /// List pending configuration changes for a specific gateway
284    ///
285    /// This endpoint returns changes specific to a gateway and is what Harmony
286    /// Proxy instances should call when polling for configuration updates
287    /// (typically every 30 seconds).
288    ///
289    /// # Authentication
290    ///
291    /// Accepts JWT tokens, Sanctum API tokens, or machine tokens.
292    ///
293    /// # Arguments
294    ///
295    /// * `token` - Authentication token (JWT, Sanctum, or machine token)
296    /// * `gateway_id` - The gateway ID to list changes for
297    ///
298    /// # Example
299    ///
300    /// ```no_run
301    /// use runbeam_sdk::RunbeamClient;
302    ///
303    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
304    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
305    /// let changes = client.list_changes_for_gateway(
306    ///     "machine_token_abc123",
307    ///     "01JBXXXXXXXXXXXXXXXXXXXXXXXXXX"
308    /// ).await?;
309    /// println!("Found {} pending changes for this gateway", changes.data.len());
310    /// # Ok(())
311    /// # }
312    /// ```
313    pub async fn list_changes_for_gateway(
314        &self,
315        token: impl Into<String>,
316        gateway_id: impl Into<String>,
317    ) -> Result<
318        crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Change>,
319        RunbeamError,
320    > {
321        let gateway_id = gateway_id.into();
322        let url = format!("{}/harmony/changes/{}", self.base_url, gateway_id);
323
324        tracing::debug!("Listing changes for gateway {} from: {}", gateway_id, url);
325
326        let response = self
327            .client
328            .get(&url)
329            .header("Authorization", format!("Bearer {}", token.into()))
330            .send()
331            .await
332            .map_err(ApiError::from)?;
333
334        if !response.status().is_success() {
335            let status = response.status();
336            let error_body = response
337                .text()
338                .await
339                .unwrap_or_else(|_| "Unknown error".to_string());
340            tracing::error!(
341                "Failed to list changes for gateway {}: HTTP {} - {}",
342                gateway_id,
343                status,
344                error_body
345            );
346            return Err(RunbeamError::Api(ApiError::Http {
347                status: status.as_u16(),
348                message: error_body,
349            }));
350        }
351
352        let response_text = response.text().await.map_err(|e| {
353            tracing::error!("Failed to read response body: {}", e);
354            RunbeamError::Api(ApiError::Parse(format!("Failed to read response: {}", e)))
355        })?;
356
357        serde_json::from_str(&response_text).map_err(|e| {
358            tracing::error!(
359                "Failed to parse changes response: {} - Response body: {}",
360                e,
361                response_text
362            );
363            RunbeamError::Api(ApiError::Parse(format!(
364                "Failed to parse response: {} - Body: {}",
365                e, response_text
366            )))
367        })
368    }
369
370    /// Get detailed information about a specific configuration change
371    ///
372    /// Retrieve full details of a change including TOML configuration content,
373    /// metadata, and status information.
374    ///
375    /// # Authentication
376    ///
377    /// Accepts JWT tokens, Sanctum API tokens, or machine tokens.
378    ///
379    /// # Arguments
380    ///
381    /// * `token` - Authentication token (JWT, Sanctum, or machine token)
382    /// * `change_id` - The change ID to retrieve
383    ///
384    /// # Example
385    ///
386    /// ```no_run
387    /// use runbeam_sdk::RunbeamClient;
388    ///
389    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
390    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
391    /// let change = client.get_change("machine_token_abc123", "change-123").await?;
392    ///
393    /// if let Some(toml_config) = &change.data.toml_config {
394    ///     println!("TOML config:\n{}", toml_config);
395    /// }
396    /// # Ok(())
397    /// # }
398    /// ```
399    pub async fn get_change(
400        &self,
401        token: impl Into<String>,
402        change_id: impl Into<String>,
403    ) -> Result<
404        crate::runbeam_api::resources::ResourceResponse<crate::runbeam_api::resources::Change>,
405        RunbeamError,
406    > {
407        let change_id = change_id.into();
408        let url = format!("{}/harmony/change/{}", self.base_url, change_id);
409
410        tracing::debug!("Getting change {} from: {}", change_id, url);
411
412        let response = self
413            .client
414            .get(&url)
415            .header("Authorization", format!("Bearer {}", token.into()))
416            .send()
417            .await
418            .map_err(ApiError::from)?;
419
420        if !response.status().is_success() {
421            let status = response.status();
422            let error_body = response
423                .text()
424                .await
425                .unwrap_or_else(|_| "Unknown error".to_string());
426            tracing::error!("Failed to get change: HTTP {} - {}", status, error_body);
427            return Err(RunbeamError::Api(ApiError::Http {
428                status: status.as_u16(),
429                message: error_body,
430            }));
431        }
432
433        response.json().await.map_err(|e| {
434            tracing::error!("Failed to parse change response: {}", e);
435            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
436        })
437    }
438
439    /// List all gateways for the authenticated team
440    ///
441    /// Returns a paginated list of gateways.
442    ///
443    /// # Authentication
444    ///
445    /// Accepts either JWT tokens or Laravel Sanctum API tokens. The token is passed
446    /// to the server for validation without local verification.
447    ///
448    /// # Arguments
449    ///
450    /// * `token` - JWT or Sanctum API token for authentication
451    pub async fn list_gateways(
452        &self,
453        token: impl Into<String>,
454    ) -> Result<
455        crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Gateway>,
456        RunbeamError,
457    > {
458        let url = format!("{}/gateways", self.base_url);
459
460        let response = self
461            .client
462            .get(&url)
463            .header("Authorization", format!("Bearer {}", token.into()))
464            .send()
465            .await
466            .map_err(ApiError::from)?;
467
468        if !response.status().is_success() {
469            let status = response.status();
470            let error_body = response
471                .text()
472                .await
473                .unwrap_or_else(|_| "Unknown error".to_string());
474            return Err(RunbeamError::Api(ApiError::Http {
475                status: status.as_u16(),
476                message: error_body,
477            }));
478        }
479
480        response.json().await.map_err(|e| {
481            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
482        })
483    }
484
485    /// Get a specific gateway by ID or code
486    ///
487    /// # Authentication
488    ///
489    /// Accepts JWT tokens, Sanctum API tokens, or machine tokens. The token is passed
490    /// to the server for validation without local verification.
491    ///
492    /// # Arguments
493    ///
494    /// * `token` - JWT, Sanctum API token, or machine token for authentication
495    /// * `gateway_id` - The gateway ID or code
496    pub async fn get_gateway(
497        &self,
498        token: impl Into<String>,
499        gateway_id: impl Into<String>,
500    ) -> Result<
501        crate::runbeam_api::resources::ResourceResponse<crate::runbeam_api::resources::Gateway>,
502        RunbeamError,
503    > {
504        let url = format!("{}/gateways/{}", self.base_url, gateway_id.into());
505
506        let response = self
507            .client
508            .get(&url)
509            .header("Authorization", format!("Bearer {}", token.into()))
510            .send()
511            .await
512            .map_err(ApiError::from)?;
513
514        if !response.status().is_success() {
515            let status = response.status();
516            let error_body = response
517                .text()
518                .await
519                .unwrap_or_else(|_| "Unknown error".to_string());
520            return Err(RunbeamError::Api(ApiError::Http {
521                status: status.as_u16(),
522                message: error_body,
523            }));
524        }
525
526        response.json().await.map_err(|e| {
527            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
528        })
529    }
530
531    /// List all services for the authenticated team
532    ///
533    /// Returns a paginated list of services across all gateways.
534    ///
535    /// # Authentication
536    ///
537    /// Accepts either JWT tokens or Laravel Sanctum API tokens. The token is passed
538    /// to the server for validation without local verification.
539    ///
540    /// # Arguments
541    ///
542    /// * `token` - JWT or Sanctum API token for authentication
543    pub async fn list_services(
544        &self,
545        token: impl Into<String>,
546    ) -> Result<
547        crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Service>,
548        RunbeamError,
549    > {
550        let url = format!("{}/api/services", self.base_url);
551
552        let response = self
553            .client
554            .get(&url)
555            .header("Authorization", format!("Bearer {}", token.into()))
556            .send()
557            .await
558            .map_err(ApiError::from)?;
559
560        if !response.status().is_success() {
561            let status = response.status();
562            let error_body = response
563                .text()
564                .await
565                .unwrap_or_else(|_| "Unknown error".to_string());
566            return Err(RunbeamError::Api(ApiError::Http {
567                status: status.as_u16(),
568                message: error_body,
569            }));
570        }
571
572        response.json().await.map_err(|e| {
573            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
574        })
575    }
576
577    /// Get a specific service by ID
578    ///
579    /// # Authentication
580    ///
581    /// Accepts JWT tokens, Sanctum API tokens, or machine tokens. The token is passed
582    /// to the server for validation without local verification.
583    ///
584    /// # Arguments
585    ///
586    /// * `token` - JWT, Sanctum API token, or machine token for authentication
587    /// * `service_id` - The service ID
588    pub async fn get_service(
589        &self,
590        token: impl Into<String>,
591        service_id: impl Into<String>,
592    ) -> Result<
593        crate::runbeam_api::resources::ResourceResponse<crate::runbeam_api::resources::Service>,
594        RunbeamError,
595    > {
596        let url = format!("{}/api/services/{}", self.base_url, service_id.into());
597
598        let response = self
599            .client
600            .get(&url)
601            .header("Authorization", format!("Bearer {}", token.into()))
602            .send()
603            .await
604            .map_err(ApiError::from)?;
605
606        if !response.status().is_success() {
607            let status = response.status();
608            let error_body = response
609                .text()
610                .await
611                .unwrap_or_else(|_| "Unknown error".to_string());
612            return Err(RunbeamError::Api(ApiError::Http {
613                status: status.as_u16(),
614                message: error_body,
615            }));
616        }
617
618        response.json().await.map_err(|e| {
619            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
620        })
621    }
622
623    /// List all endpoints for the authenticated team
624    ///
625    /// # Authentication
626    ///
627    /// Accepts either JWT tokens or Laravel Sanctum API tokens. The token is passed
628    /// to the server for validation without local verification.
629    ///
630    /// # Arguments
631    ///
632    /// * `token` - JWT or Sanctum API token for authentication
633    pub async fn list_endpoints(
634        &self,
635        token: impl Into<String>,
636    ) -> Result<
637        crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Endpoint>,
638        RunbeamError,
639    > {
640        let url = format!("{}/api/endpoints", self.base_url);
641
642        let response = self
643            .client
644            .get(&url)
645            .header("Authorization", format!("Bearer {}", token.into()))
646            .send()
647            .await
648            .map_err(ApiError::from)?;
649
650        if !response.status().is_success() {
651            let status = response.status();
652            let error_body = response
653                .text()
654                .await
655                .unwrap_or_else(|_| "Unknown error".to_string());
656            return Err(RunbeamError::Api(ApiError::Http {
657                status: status.as_u16(),
658                message: error_body,
659            }));
660        }
661
662        response.json().await.map_err(|e| {
663            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
664        })
665    }
666
667    /// List all backends for the authenticated team
668    ///
669    /// # Authentication
670    ///
671    /// Accepts either JWT tokens or Laravel Sanctum API tokens. The token is passed
672    /// to the server for validation without local verification.
673    ///
674    /// # Arguments
675    ///
676    /// * `token` - JWT or Sanctum API token for authentication
677    pub async fn list_backends(
678        &self,
679        token: impl Into<String>,
680    ) -> Result<
681        crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Backend>,
682        RunbeamError,
683    > {
684        let url = format!("{}/api/backends", self.base_url);
685
686        let response = self
687            .client
688            .get(&url)
689            .header("Authorization", format!("Bearer {}", token.into()))
690            .send()
691            .await
692            .map_err(ApiError::from)?;
693
694        if !response.status().is_success() {
695            let status = response.status();
696            let error_body = response
697                .text()
698                .await
699                .unwrap_or_else(|_| "Unknown error".to_string());
700            return Err(RunbeamError::Api(ApiError::Http {
701                status: status.as_u16(),
702                message: error_body,
703            }));
704        }
705
706        response.json().await.map_err(|e| {
707            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
708        })
709    }
710
711    /// List all pipelines for the authenticated team
712    ///
713    /// # Authentication
714    ///
715    /// Accepts either JWT tokens or Laravel Sanctum API tokens. The token is passed
716    /// to the server for validation without local verification.
717    ///
718    /// # Arguments
719    ///
720    /// * `token` - JWT or Sanctum API token for authentication
721    pub async fn list_pipelines(
722        &self,
723        token: impl Into<String>,
724    ) -> Result<
725        crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Pipeline>,
726        RunbeamError,
727    > {
728        let url = format!("{}/api/pipelines", self.base_url);
729
730        let response = self
731            .client
732            .get(&url)
733            .header("Authorization", format!("Bearer {}", token.into()))
734            .send()
735            .await
736            .map_err(ApiError::from)?;
737
738        if !response.status().is_success() {
739            let status = response.status();
740            let error_body = response
741                .text()
742                .await
743                .unwrap_or_else(|_| "Unknown error".to_string());
744            return Err(RunbeamError::Api(ApiError::Http {
745                status: status.as_u16(),
746                message: error_body,
747            }));
748        }
749
750        response.json().await.map_err(|e| {
751            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
752        })
753    }
754
755    /// Get a specific transform by ID
756    ///
757    /// Retrieve transform details including the JOLT specification stored in
758    /// the `options.instructions` field. Used by Harmony Proxy to download
759    /// transform specifications when applying cloud-sourced pipeline configurations.
760    ///
761    /// # Authentication
762    ///
763    /// Accepts JWT tokens, Sanctum API tokens, or machine tokens. The token is passed
764    /// to the server for validation without local verification.
765    ///
766    /// # Arguments
767    ///
768    /// * `token` - JWT, Sanctum API token, or machine token for authentication
769    /// * `transform_id` - The transform ID (ULID format)
770    ///
771    /// # Example
772    ///
773    /// ```no_run
774    /// use runbeam_sdk::RunbeamClient;
775    ///
776    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
777    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
778    /// let transform = client.get_transform("machine_token", "01k81xczrw551e1qj9rgrf0319").await?;
779    ///
780    /// // Extract JOLT specification
781    /// if let Some(options) = &transform.data.options {
782    ///     if let Some(instructions) = &options.instructions {
783    ///         println!("JOLT spec: {}", instructions);
784    ///     }
785    /// }
786    /// # Ok(())
787    /// # }
788    /// ```
789    pub async fn get_transform(
790        &self,
791        token: impl Into<String>,
792        transform_id: impl Into<String>,
793    ) -> Result<
794        crate::runbeam_api::resources::ResourceResponse<crate::runbeam_api::resources::Transform>,
795        RunbeamError,
796    > {
797        let transform_id = transform_id.into();
798        let url = format!("{}/api/transforms/{}", self.base_url, transform_id);
799
800        tracing::debug!("Getting transform {} from: {}", transform_id, url);
801
802        let response = self
803            .client
804            .get(&url)
805            .header("Authorization", format!("Bearer {}", token.into()))
806            .send()
807            .await
808            .map_err(ApiError::from)?;
809
810        if !response.status().is_success() {
811            let status = response.status();
812            let error_body = response
813                .text()
814                .await
815                .unwrap_or_else(|_| "Unknown error".to_string());
816            tracing::error!("Failed to get transform: HTTP {} - {}", status, error_body);
817            return Err(RunbeamError::Api(ApiError::Http {
818                status: status.as_u16(),
819                message: error_body,
820            }));
821        }
822
823        response.json().await.map_err(|e| {
824            tracing::error!("Failed to parse transform response: {}", e);
825            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
826        })
827    }
828
829    // ========== Change Management API Methods (v1.2) ==========
830
831    /// Get the base URL for the changes API
832    ///
833    /// Service discovery endpoint that returns the base URL for the changes API.
834    /// Harmony Proxy instances can call this to discover the API location dynamically.
835    ///
836    /// # Authentication
837    ///
838    /// Accepts JWT tokens, Sanctum API tokens, or machine tokens.
839    ///
840    /// # Arguments
841    ///
842    /// * `token` - Authentication token (JWT, Sanctum, or machine token)
843    ///
844    /// # Example
845    ///
846    /// ```no_run
847    /// use runbeam_sdk::RunbeamClient;
848    ///
849    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
850    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
851    /// let response = client.get_base_url("machine_token_abc123").await?;
852    /// println!("Changes API base URL: {}", response.base_url);
853    /// # Ok(())
854    /// # }
855    /// ```
856    pub async fn get_base_url(
857        &self,
858        token: impl Into<String>,
859    ) -> Result<crate::runbeam_api::resources::BaseUrlResponse, RunbeamError> {
860        let token = token.into();
861        // Try both with and without "/api" to support configs that provide either
862        let candidates = [
863            format!("{}/api/harmony/base-url", self.base_url),
864            format!("{}/harmony/base-url", self.base_url),
865        ];
866
867        let mut last_err: Option<RunbeamError> = None;
868        for url in candidates {
869            tracing::debug!("Getting base URL from: {}", url);
870            let resp = self
871                .client
872                .get(&url)
873                .header("Authorization", format!("Bearer {}", token))
874                .send()
875                .await;
876
877            let response = match resp {
878                Ok(r) => r,
879                Err(e) => {
880                    last_err = Some(ApiError::from(e).into());
881                    continue;
882                }
883            };
884
885            if !response.status().is_success() {
886                let status = response.status();
887                let error_body = response
888                    .text()
889                    .await
890                    .unwrap_or_else(|_| "Unknown error".to_string());
891                tracing::warn!(
892                    "Base URL discovery attempt failed: HTTP {} - {} (url: {})",
893                    status,
894                    error_body,
895                    url
896                );
897                last_err = Some(RunbeamError::Api(ApiError::Http {
898                    status: status.as_u16(),
899                    message: error_body,
900                }));
901                continue;
902            }
903
904            let parsed = response.json().await.map_err(|e| {
905                tracing::warn!("Failed to parse base URL response from {}: {}", url, e);
906                RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
907            });
908            if parsed.is_ok() {
909                return parsed;
910            } else {
911                last_err = Some(parsed.err().unwrap());
912            }
913        }
914
915        Err(last_err.unwrap_or_else(|| {
916            RunbeamError::Api(ApiError::Request(
917                "Base URL discovery failed for all candidates".to_string(),
918            ))
919        }))
920    }
921
922    /// Discover and return a new client with the resolved base URL
923    pub async fn discover_base_url(&self, token: impl Into<String>) -> Result<Self, RunbeamError> {
924        let resp = self.get_base_url(token).await?;
925        let discovered = resp.full_url.unwrap_or(resp.base_url);
926        tracing::info!("Discovered Runbeam API base URL: {}", discovered);
927        Ok(Self::new(discovered))
928    }
929
930    /// Acknowledge receipt of multiple configuration changes
931    ///
932    /// Bulk acknowledge that changes have been received. Gateways should call this
933    /// immediately after retrieving changes to update their status from "pending"
934    /// to "acknowledged".
935    ///
936    /// # Authentication
937    ///
938    /// Accepts JWT tokens, Sanctum API tokens, or machine tokens.
939    ///
940    /// # Arguments
941    ///
942    /// * `token` - Authentication token (JWT, Sanctum, or machine token)
943    /// * `change_ids` - Vector of change IDs to acknowledge
944    ///
945    /// # Example
946    ///
947    /// ```no_run
948    /// use runbeam_sdk::RunbeamClient;
949    ///
950    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
951    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
952    /// let change_ids = vec!["change-1".to_string(), "change-2".to_string()];
953    /// client.acknowledge_changes("machine_token_abc123", change_ids).await?;
954    /// # Ok(())
955    /// # }
956    /// ```
957    pub async fn acknowledge_changes(
958        &self,
959        token: impl Into<String>,
960        change_ids: Vec<String>,
961    ) -> Result<crate::runbeam_api::resources::AcknowledgeChangesResponse, RunbeamError> {
962        let url = format!("{}/harmony/changes/acknowledge", self.base_url);
963
964        tracing::info!("Acknowledging {} changes", change_ids.len());
965        tracing::debug!("Change IDs: {:?}", change_ids);
966
967        let payload = crate::runbeam_api::resources::AcknowledgeChangesRequest { change_ids };
968
969        let response = self
970            .client
971            .post(&url)
972            .header("Authorization", format!("Bearer {}", token.into()))
973            .header("Content-Type", "application/json")
974            .json(&payload)
975            .send()
976            .await
977            .map_err(ApiError::from)?;
978
979        if !response.status().is_success() {
980            let status = response.status();
981            let error_body = response
982                .text()
983                .await
984                .unwrap_or_else(|_| "Unknown error".to_string());
985            tracing::error!(
986                "Failed to acknowledge changes: HTTP {} - {}",
987                status,
988                error_body
989            );
990            return Err(RunbeamError::Api(ApiError::Http {
991                status: status.as_u16(),
992                message: error_body,
993            }));
994        }
995
996        response.json().await.map_err(|e| {
997            tracing::error!("Failed to parse acknowledge response: {}", e);
998            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
999        })
1000    }
1001
1002    /// Mark a configuration change as successfully applied
1003    ///
1004    /// Report that a change has been successfully applied to the gateway configuration.
1005    /// This updates the change status to "applied".
1006    ///
1007    /// # Authentication
1008    ///
1009    /// Accepts JWT tokens, Sanctum API tokens, or machine tokens.
1010    ///
1011    /// # Arguments
1012    ///
1013    /// * `token` - Authentication token (JWT, Sanctum, or machine token)
1014    /// * `change_id` - The change ID that was applied
1015    ///
1016    /// # Example
1017    ///
1018    /// ```no_run
1019    /// use runbeam_sdk::RunbeamClient;
1020    ///
1021    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1022    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
1023    /// client.mark_change_applied("machine_token_abc123", "change-123").await?;
1024    /// # Ok(())
1025    /// # }
1026    /// ```
1027    pub async fn mark_change_applied(
1028        &self,
1029        token: impl Into<String>,
1030        change_id: impl Into<String>,
1031    ) -> Result<crate::runbeam_api::resources::ChangeAppliedResponse, RunbeamError> {
1032        let change_id = change_id.into();
1033        let url = format!("{}/harmony/change/{}/applied", self.base_url, change_id);
1034
1035        tracing::info!("Marking change {} as applied", change_id);
1036
1037        let response = self
1038            .client
1039            .post(&url)
1040            .header("Authorization", format!("Bearer {}", token.into()))
1041            .send()
1042            .await
1043            .map_err(ApiError::from)?;
1044
1045        if !response.status().is_success() {
1046            let status = response.status();
1047            let error_body = response
1048                .text()
1049                .await
1050                .unwrap_or_else(|_| "Unknown error".to_string());
1051            tracing::error!(
1052                "Failed to mark change as applied: HTTP {} - {}",
1053                status,
1054                error_body
1055            );
1056            return Err(RunbeamError::Api(ApiError::Http {
1057                status: status.as_u16(),
1058                message: error_body,
1059            }));
1060        }
1061
1062        response.json().await.map_err(|e| {
1063            tracing::error!("Failed to parse applied response: {}", e);
1064            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
1065        })
1066    }
1067
1068    /// Mark a configuration change as failed with error details
1069    ///
1070    /// Report that a change failed to apply, including error details for troubleshooting.
1071    /// This updates the change status to "failed" and stores the error information.
1072    ///
1073    /// # Authentication
1074    ///
1075    /// Accepts JWT tokens, Sanctum API tokens, or machine tokens.
1076    ///
1077    /// # Arguments
1078    ///
1079    /// * `token` - Authentication token (JWT, Sanctum, or machine token)
1080    /// * `change_id` - The change ID that failed
1081    /// * `error` - Error message describing what went wrong
1082    /// * `details` - Optional additional error details
1083    ///
1084    /// # Example
1085    ///
1086    /// ```no_run
1087    /// use runbeam_sdk::RunbeamClient;
1088    ///
1089    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1090    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
1091    /// client.mark_change_failed(
1092    ///     "machine_token_abc123",
1093    ///     "change-123",
1094    ///     "Failed to parse configuration".to_string(),
1095    ///     Some(vec!["Invalid JSON syntax at line 42".to_string()])
1096    /// ).await?;
1097    /// # Ok(())
1098    /// # }
1099    /// ```
1100    pub async fn mark_change_failed(
1101        &self,
1102        token: impl Into<String>,
1103        change_id: impl Into<String>,
1104        error: String,
1105        details: Option<Vec<String>>,
1106    ) -> Result<crate::runbeam_api::resources::ChangeFailedResponse, RunbeamError> {
1107        let change_id = change_id.into();
1108        let url = format!("{}/harmony/change/{}/failed", self.base_url, change_id);
1109
1110        tracing::warn!("Marking change {} as failed: {}", change_id, error);
1111        if let Some(ref details) = details {
1112            tracing::debug!("Failure details: {:?}", details);
1113        }
1114
1115        let payload = crate::runbeam_api::resources::ChangeFailedRequest { error, details };
1116
1117        let response = self
1118            .client
1119            .post(&url)
1120            .header("Authorization", format!("Bearer {}", token.into()))
1121            .header("Content-Type", "application/json")
1122            .json(&payload)
1123            .send()
1124            .await
1125            .map_err(ApiError::from)?;
1126
1127        if !response.status().is_success() {
1128            let status = response.status();
1129            let error_body = response
1130                .text()
1131                .await
1132                .unwrap_or_else(|_| "Unknown error".to_string());
1133            tracing::error!(
1134                "Failed to mark change as failed: HTTP {} - {}",
1135                status,
1136                error_body
1137            );
1138            return Err(RunbeamError::Api(ApiError::Http {
1139                status: status.as_u16(),
1140                message: error_body,
1141            }));
1142        }
1143
1144        response.json().await.map_err(|e| {
1145            tracing::error!("Failed to parse failed response: {}", e);
1146            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
1147        })
1148    }
1149
1150    /// Store or update Harmony configuration in Runbeam Cloud
1151    ///
1152    /// This method sends TOML configuration from Harmony instances back to Runbeam Cloud
1153    /// where it is parsed and stored as database models. This is the inverse of the TOML
1154    /// generation/download API - it enables Harmony to push configuration updates to the cloud.
1155    ///
1156    /// # Authentication
1157    ///
1158    /// Accepts JWT tokens, Sanctum API tokens, or machine tokens. The token is passed
1159    /// to the server for validation without local verification.
1160    ///
1161    /// # Arguments
1162    ///
1163    /// * `token` - Authentication token (JWT, Sanctum, or machine token)
1164    /// * `config_type` - Type of configuration ("gateway", "pipeline", or "transform")
1165    /// * `id` - Optional resource ID for updates (omit for new resources)
1166    /// * `config` - TOML configuration content as a string
1167    ///
1168    /// # Returns
1169    ///
1170    /// Returns `Ok(StoreConfigResponse)` with status 200 on success, or `Err(RunbeamError)`
1171    /// if the operation fails (404 for not found, 422 for validation errors).
1172    ///
1173    /// # Examples
1174    ///
1175    /// ## Creating a new gateway configuration
1176    ///
1177    /// ```no_run
1178    /// use runbeam_sdk::RunbeamClient;
1179    ///
1180    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1181    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
1182    /// let toml_config = r#"
1183    /// [proxy]
1184    /// id = "gateway-123"
1185    /// name = "Production Gateway"
1186    /// "#;
1187    ///
1188    /// let response = client.store_config(
1189    ///     "machine_token_abc123",
1190    ///     "gateway",
1191    ///     None,  // No ID = create new
1192    ///     toml_config
1193    /// ).await?;
1194    ///
1195    /// println!("Configuration stored: success={}, message={}", response.success, response.message);
1196    /// # Ok(())
1197    /// # }
1198    /// ```
1199    ///
1200    /// ## Updating an existing pipeline configuration
1201    ///
1202    /// ```no_run
1203    /// use runbeam_sdk::RunbeamClient;
1204    ///
1205    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1206    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
1207    /// let toml_config = r#"
1208    /// [pipeline]
1209    /// name = "Updated Pipeline"
1210    /// description = "Modified configuration"
1211    /// "#;
1212    ///
1213    /// let response = client.store_config(
1214    ///     "machine_token_abc123",
1215    ///     "pipeline",
1216    ///     Some("01k8pipeline123".to_string()),  // With ID = update existing
1217    ///     toml_config
1218    /// ).await?;
1219    ///
1220    /// println!("Configuration updated: model_id={}", response.data.id);
1221    /// # Ok(())
1222    /// # }
1223    /// ```
1224    pub async fn store_config(
1225        &self,
1226        token: impl Into<String>,
1227        config_type: impl Into<String>,
1228        id: Option<String>,
1229        config: impl Into<String>,
1230    ) -> Result<StoreConfigResponse, RunbeamError> {
1231        let config_type = config_type.into();
1232        let config = config.into();
1233        let url = format!("{}/harmony/update", self.base_url);
1234
1235        tracing::info!(
1236            "Storing {} configuration to Runbeam Cloud (id: {:?})",
1237            config_type,
1238            id
1239        );
1240        tracing::debug!("Configuration length: {} bytes", config.len());
1241
1242        let payload = StoreConfigRequest {
1243            config_type: config_type.clone(),
1244            id: id.clone(),
1245            config,
1246        };
1247
1248        let response = self
1249            .client
1250            .post(&url)
1251            .header("Authorization", format!("Bearer {}", token.into()))
1252            .header("Content-Type", "application/json")
1253            .json(&payload)
1254            .send()
1255            .await
1256            .map_err(|e| {
1257                tracing::error!("Failed to send store config request: {}", e);
1258                ApiError::from(e)
1259            })?;
1260
1261        let status = response.status();
1262        tracing::debug!("Received response with status: {}", status);
1263
1264        // Handle error responses
1265        if !status.is_success() {
1266            let error_body = response
1267                .text()
1268                .await
1269                .unwrap_or_else(|_| "Unknown error".to_string());
1270
1271            tracing::error!(
1272                "Store config failed: HTTP {} - {}",
1273                status.as_u16(),
1274                error_body
1275            );
1276
1277            return Err(RunbeamError::Api(ApiError::Http {
1278                status: status.as_u16(),
1279                message: error_body,
1280            }));
1281        }
1282
1283        // Parse successful response (UpdateSuccessResource format)
1284        let body_text = response.text().await.map_err(|e| {
1285            tracing::error!("Failed to read response body: {}", e);
1286            ApiError::Network(format!("Failed to read response body: {}", e))
1287        })?;
1288
1289        let response_data =
1290            serde_json::from_str::<StoreConfigResponse>(&body_text).map_err(|e| {
1291                tracing::error!("Failed to parse store config response: {}", e);
1292                tracing::error!("Response body was: {}", body_text);
1293                ApiError::Parse(format!("Failed to parse response: {}", e))
1294            })?;
1295
1296        tracing::info!(
1297            "Configuration stored successfully: type={}, id={:?}, action={}",
1298            config_type,
1299            id,
1300            response_data.data.action
1301        );
1302
1303        Ok(response_data)
1304    }
1305
1306    // ========== Mesh Authentication API Methods ==========
1307
1308    /// Request a mesh authentication token
1309    ///
1310    /// Request a JWT for authenticating to another mesh member. The requesting gateway
1311    /// must have an enabled egress in the specified mesh, and the destination URL must
1312    /// match an ingress URL pattern in the mesh.
1313    ///
1314    /// # Authentication
1315    ///
1316    /// Requires a gateway machine token.
1317    ///
1318    /// # Arguments
1319    ///
1320    /// * `token` - Gateway machine token for authentication
1321    /// * `mesh_id` - The mesh ID to authenticate against
1322    /// * `destination_url` - The destination URL the token is being requested for
1323    ///
1324    /// # Returns
1325    ///
1326    /// Returns `Ok(MeshTokenResponse)` with the signed JWT and expiry,
1327    /// or `Err(RunbeamError)` if the request fails.
1328    ///
1329    /// # Example
1330    ///
1331    /// ```no_run
1332    /// use runbeam_sdk::RunbeamClient;
1333    ///
1334    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1335    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
1336    /// let response = client.request_mesh_token(
1337    ///     "machine_token_abc123",
1338    ///     "01HXYZ123456789ABCDEF",
1339    ///     "https://partner.example.com/fhir/r4/Patient"
1340    /// ).await?;
1341    ///
1342    /// println!("Token: {}", response.token);
1343    /// println!("Expires at: {}", response.expires_at);
1344    /// # Ok(())
1345    /// # }
1346    /// ```
1347    pub async fn request_mesh_token(
1348        &self,
1349        token: impl Into<String>,
1350        mesh_id: impl Into<String>,
1351        destination_url: impl Into<String>,
1352    ) -> Result<crate::runbeam_api::resources::MeshTokenResponse, RunbeamError> {
1353        let mesh_id = mesh_id.into();
1354        let destination_url = destination_url.into();
1355        let url = format!("{}/harmony/mesh/token", self.base_url);
1356
1357        tracing::debug!(
1358            "Requesting mesh token: mesh_id={}, destination={}",
1359            mesh_id,
1360            destination_url
1361        );
1362
1363        let payload = crate::runbeam_api::resources::MeshTokenRequest {
1364            mesh_id: mesh_id.clone(),
1365            destination_url: destination_url.clone(),
1366        };
1367
1368        let response = self
1369            .client
1370            .post(&url)
1371            .header("Authorization", format!("Bearer {}", token.into()))
1372            .header("Content-Type", "application/json")
1373            .json(&payload)
1374            .send()
1375            .await
1376            .map_err(|e| {
1377                tracing::error!("Failed to send mesh token request: {}", e);
1378                ApiError::from(e)
1379            })?;
1380
1381        let status = response.status();
1382        tracing::debug!("Received response with status: {}", status);
1383
1384        if !status.is_success() {
1385            let error_body = response
1386                .text()
1387                .await
1388                .unwrap_or_else(|_| "Unknown error".to_string());
1389
1390            tracing::error!(
1391                "Mesh token request failed: HTTP {} - {}",
1392                status.as_u16(),
1393                error_body
1394            );
1395
1396            return Err(RunbeamError::Api(ApiError::Http {
1397                status: status.as_u16(),
1398                message: error_body,
1399            }));
1400        }
1401
1402        let token_response: crate::runbeam_api::resources::MeshTokenResponse =
1403            response.json().await.map_err(|e| {
1404                tracing::error!("Failed to parse mesh token response: {}", e);
1405                ApiError::Parse(format!("Failed to parse response: {}", e))
1406            })?;
1407
1408        tracing::info!(
1409            "Mesh token obtained: mesh_id={}, expires_at={}",
1410            token_response.mesh_id,
1411            token_response.expires_at
1412        );
1413
1414        Ok(token_response)
1415    }
1416
1417    // ========== Resource Resolution API Methods ==========
1418
1419    /// Resolve a resource reference
1420    ///
1421    /// Resolve a provider-based resource reference to its full definition.
1422    /// This is used to look up resources like mesh ingress/egress by their
1423    /// reference string (e.g., `runbeam.acme.ingress.name.patient_api`).
1424    ///
1425    /// # Authentication
1426    ///
1427    /// Requires a gateway machine token.
1428    ///
1429    /// # Arguments
1430    ///
1431    /// * `token` - Gateway machine token for authentication
1432    /// * `reference` - The resource reference string to resolve
1433    ///
1434    /// # Returns
1435    ///
1436    /// Returns `Ok(ResolveResourceResponse)` with the resolved resource,
1437    /// or `Err(RunbeamError)` if resolution fails.
1438    ///
1439    /// # Example
1440    ///
1441    /// ```no_run
1442    /// use runbeam_sdk::RunbeamClient;
1443    ///
1444    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1445    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
1446    /// let response = client.resolve_reference(
1447    ///     "machine_token_abc123",
1448    ///     "runbeam.acme.ingress.name.patient_api"
1449    /// ).await?;
1450    ///
1451    /// println!("Resolved: {} ({})", response.data.name, response.data.resource_type);
1452    /// println!("URLs: {:?}", response.data.urls);
1453    /// # Ok(())
1454    /// # }
1455    /// ```
1456    pub async fn resolve_reference(
1457        &self,
1458        token: impl Into<String>,
1459        reference: impl Into<String>,
1460    ) -> Result<crate::runbeam_api::types::ResolveResourceResponse, RunbeamError> {
1461        let reference = reference.into();
1462        let url = format!(
1463            "{}/harmony/resources/resolve?ref={}",
1464            self.base_url,
1465            urlencoding::encode(&reference)
1466        );
1467
1468        tracing::debug!("Resolving resource reference: {}", reference);
1469
1470        let response = self
1471            .client
1472            .get(&url)
1473            .header("Authorization", format!("Bearer {}", token.into()))
1474            .send()
1475            .await
1476            .map_err(|e| {
1477                tracing::error!("Failed to send resolve request: {}", e);
1478                ApiError::from(e)
1479            })?;
1480
1481        let status = response.status();
1482        tracing::debug!("Received response with status: {}", status);
1483
1484        if !status.is_success() {
1485            let error_body = response
1486                .text()
1487                .await
1488                .unwrap_or_else(|_| "Unknown error".to_string());
1489
1490            tracing::error!(
1491                "Resource resolution failed: HTTP {} - {}",
1492                status.as_u16(),
1493                error_body
1494            );
1495
1496            return Err(RunbeamError::Api(ApiError::Http {
1497                status: status.as_u16(),
1498                message: error_body,
1499            }));
1500        }
1501
1502        let resolve_response: crate::runbeam_api::types::ResolveResourceResponse =
1503            response.json().await.map_err(|e| {
1504                tracing::error!("Failed to parse resolve response: {}", e);
1505                ApiError::Parse(format!("Failed to parse response: {}", e))
1506            })?;
1507
1508        tracing::info!(
1509            "Resource resolved: {} ({}) from provider {}",
1510            resolve_response.data.name,
1511            resolve_response.data.resource_type,
1512            resolve_response.meta.provider
1513        );
1514
1515        Ok(resolve_response)
1516    }
1517}
1518
1519#[cfg(test)]
1520mod tests {
1521    use super::*;
1522
1523    #[test]
1524    fn test_client_creation() {
1525        let client = RunbeamClient::new("http://example.com");
1526        assert_eq!(client.base_url(), "http://example.com");
1527    }
1528
1529    #[test]
1530    fn test_client_creation_with_string() {
1531        let base_url = String::from("http://example.com");
1532        let client = RunbeamClient::new(base_url);
1533        assert_eq!(client.base_url(), "http://example.com");
1534    }
1535
1536    #[test]
1537    fn test_authorize_request_serialization() {
1538        let request = AuthorizeRequest {
1539            token: "test_token".to_string(),
1540            gateway_code: "gw123".to_string(),
1541            machine_public_key: Some("pubkey123".to_string()),
1542            metadata: None,
1543        };
1544
1545        let json = serde_json::to_string(&request).unwrap();
1546        assert!(json.contains("\"token\":\"test_token\""));
1547        assert!(json.contains("\"gateway_code\":\"gw123\""));
1548        assert!(json.contains("\"machine_public_key\":\"pubkey123\""));
1549    }
1550
1551    #[test]
1552    fn test_authorize_request_serialization_without_optional_fields() {
1553        let request = AuthorizeRequest {
1554            token: "test_token".to_string(),
1555            gateway_code: "gw123".to_string(),
1556            machine_public_key: None,
1557            metadata: None,
1558        };
1559
1560        let json = serde_json::to_string(&request).unwrap();
1561        assert!(json.contains("\"token\":\"test_token\""));
1562        assert!(json.contains("\"gateway_code\":\"gw123\""));
1563        // Should not contain the optional fields
1564        assert!(!json.contains("machine_public_key"));
1565        assert!(!json.contains("metadata"));
1566    }
1567
1568    #[test]
1569    fn test_change_serialization() {
1570        use crate::runbeam_api::resources::Change;
1571
1572        // Test Change with metadata (list view)
1573        let change_metadata = Change {
1574            id: "01JBXXXXXXXXXXXXXXXXXXXXXXXXXX".to_string(),
1575            status: Some("pending".to_string()),
1576            resource_type: "gateway".to_string(),
1577            gateway_id: "01JBXXXXXXXXXXXXXXXXXXXXXXXXXX".to_string(),
1578            pipeline_id: None,
1579            toml_config: None,
1580            metadata: None,
1581            created_at: "2025-01-07T01:00:00+00:00".to_string(),
1582            acknowledged_at: None,
1583            applied_at: None,
1584            failed_at: None,
1585            error_message: None,
1586            error_details: None,
1587        };
1588
1589        let json = serde_json::to_string(&change_metadata).unwrap();
1590        assert!(json.contains("\"id\":\"01JBXXXXXXXXXXXXXXXXXXXXXXXXXX\""));
1591        assert!(json.contains("\"gateway_id\":\"01JBXXXXXXXXXXXXXXXXXXXXXXXXXX\""));
1592        assert!(json.contains("\"type\":\"gateway\""));
1593
1594        // Test deserialization
1595        let deserialized: Change = serde_json::from_str(&json).unwrap();
1596        assert_eq!(deserialized.id, "01JBXXXXXXXXXXXXXXXXXXXXXXXXXX");
1597        assert_eq!(deserialized.status, Some("pending".to_string()));
1598        assert_eq!(deserialized.resource_type, "gateway");
1599
1600        // Test Change with full details (detail view)
1601        let change_detail = Change {
1602            id: "01JBXXXXXXXXXXXXXXXXXXXXXXXXXX".to_string(),
1603            status: Some("applied".to_string()),
1604            resource_type: "gateway".to_string(),
1605            gateway_id: "01JBXXXXXXXXXXXXXXXXXXXXXXXXXX".to_string(),
1606            pipeline_id: None,
1607            toml_config: Some("[proxy]\nname = \"test\"".to_string()),
1608            metadata: Some(serde_json::json!({"gateway_name": "test-gateway"})),
1609            created_at: "2025-01-07T01:00:00+00:00".to_string(),
1610            acknowledged_at: Some("2025-01-07T01:00:05+00:00".to_string()),
1611            applied_at: Some("2025-01-07T01:00:10+00:00".to_string()),
1612            failed_at: None,
1613            error_message: None,
1614            error_details: None,
1615        };
1616
1617        let json = serde_json::to_string(&change_detail).unwrap();
1618        assert!(json.contains("toml_config"));
1619        assert!(json.contains("acknowledged_at"));
1620        assert!(json.contains("applied_at"));
1621
1622        // Test deserialization of detail view
1623        let deserialized: Change = serde_json::from_str(&json).unwrap();
1624        assert!(deserialized.toml_config.is_some());
1625        assert!(deserialized.acknowledged_at.is_some());
1626        assert!(deserialized.applied_at.is_some());
1627    }
1628
1629    #[test]
1630    fn test_acknowledge_changes_request_serialization() {
1631        use crate::runbeam_api::resources::AcknowledgeChangesRequest;
1632
1633        let request = AcknowledgeChangesRequest {
1634            change_ids: vec![
1635                "change-1".to_string(),
1636                "change-2".to_string(),
1637                "change-3".to_string(),
1638            ],
1639        };
1640
1641        let json = serde_json::to_string(&request).unwrap();
1642        assert!(json.contains("\"change_ids\""));
1643        assert!(json.contains("\"change-1\""));
1644        assert!(json.contains("\"change-2\""));
1645        assert!(json.contains("\"change-3\""));
1646
1647        // Test deserialization
1648        let deserialized: AcknowledgeChangesRequest = serde_json::from_str(&json).unwrap();
1649        assert_eq!(deserialized.change_ids.len(), 3);
1650        assert_eq!(deserialized.change_ids[0], "change-1");
1651    }
1652
1653    #[test]
1654    fn test_change_failed_request_serialization() {
1655        use crate::runbeam_api::resources::ChangeFailedRequest;
1656
1657        // Test with details
1658        let request_with_details = ChangeFailedRequest {
1659            error: "Configuration parse error".to_string(),
1660            details: Some(vec![
1661                "Invalid JSON at line 42".to_string(),
1662                "Missing required field 'name'".to_string(),
1663            ]),
1664        };
1665
1666        let json = serde_json::to_string(&request_with_details).unwrap();
1667        assert!(json.contains("\"error\":\"Configuration parse error\""));
1668        assert!(json.contains("\"details\""));
1669        assert!(json.contains("Invalid JSON at line 42"));
1670
1671        // Test without details (should omit the field)
1672        let request_without_details = ChangeFailedRequest {
1673            error: "Unknown error".to_string(),
1674            details: None,
1675        };
1676
1677        let json = serde_json::to_string(&request_without_details).unwrap();
1678        assert!(json.contains("\"error\":\"Unknown error\""));
1679        assert!(!json.contains("\"details\"")); // Should be omitted due to skip_serializing_if
1680
1681        // Test deserialization
1682        let deserialized: ChangeFailedRequest =
1683            serde_json::from_str(&serde_json::to_string(&request_with_details).unwrap()).unwrap();
1684        assert_eq!(deserialized.error, "Configuration parse error");
1685        assert!(deserialized.details.is_some());
1686        assert_eq!(deserialized.details.unwrap().len(), 2);
1687    }
1688
1689    #[test]
1690    fn test_base_url_response_serialization() {
1691        use crate::runbeam_api::resources::BaseUrlResponse;
1692
1693        let response = BaseUrlResponse {
1694            base_url: "https://api.runbeam.io".to_string(),
1695            changes_path: Some("/api/changes".to_string()),
1696            full_url: Some("https://api.runbeam.io/api/changes".to_string()),
1697        };
1698
1699        let json = serde_json::to_string(&response).unwrap();
1700        assert!(json.contains("\"base_url\":\"https://api.runbeam.io\""));
1701
1702        // Test deserialization
1703        let deserialized: BaseUrlResponse = serde_json::from_str(&json).unwrap();
1704        assert_eq!(deserialized.base_url, "https://api.runbeam.io");
1705        assert_eq!(deserialized.changes_path, Some("/api/changes".to_string()));
1706        assert_eq!(
1707            deserialized.full_url,
1708            Some("https://api.runbeam.io/api/changes".to_string())
1709        );
1710    }
1711
1712    #[test]
1713    fn test_store_config_request_serialization_with_id() {
1714        let request = StoreConfigRequest {
1715            config_type: "gateway".to_string(),
1716            id: Some("01k8ek6h9aahhnrv3benret1nn".to_string()),
1717            config: "[proxy]\nid = \"test\"\n".to_string(),
1718        };
1719
1720        let json = serde_json::to_string(&request).unwrap();
1721        // Verify field renaming: config_type -> "type"
1722        assert!(json.contains("\"type\":\"gateway\""));
1723        assert!(json.contains("\"id\":\"01k8ek6h9aahhnrv3benret1nn\""));
1724        assert!(json.contains("\"config\":"));
1725        assert!(json.contains("[proxy]"));
1726
1727        // Test deserialization
1728        let deserialized: StoreConfigRequest = serde_json::from_str(&json).unwrap();
1729        assert_eq!(deserialized.config_type, "gateway");
1730        assert_eq!(
1731            deserialized.id,
1732            Some("01k8ek6h9aahhnrv3benret1nn".to_string())
1733        );
1734    }
1735
1736    #[test]
1737    fn test_store_config_request_serialization_without_id() {
1738        let request = StoreConfigRequest {
1739            config_type: "pipeline".to_string(),
1740            id: None,
1741            config: "[pipeline]\nname = \"test\"\n".to_string(),
1742        };
1743
1744        let json = serde_json::to_string(&request).unwrap();
1745        assert!(json.contains("\"type\":\"pipeline\""));
1746        assert!(json.contains("\"config\":"));
1747        // Should not contain the id field when None
1748        assert!(!json.contains("\"id\""));
1749
1750        // Test deserialization
1751        let deserialized: StoreConfigRequest = serde_json::from_str(&json).unwrap();
1752        assert_eq!(deserialized.config_type, "pipeline");
1753        assert_eq!(deserialized.id, None);
1754    }
1755
1756    #[test]
1757    fn test_store_config_request_field_rename() {
1758        // Test that the "type" JSON field correctly maps to config_type
1759        let json = r#"{"type":"transform","config":"[transform]\nname = \"test\"\n"}"#;
1760        let request: StoreConfigRequest = serde_json::from_str(json).unwrap();
1761        assert_eq!(request.config_type, "transform");
1762        assert_eq!(request.id, None);
1763
1764        // Serialize back and verify it uses "type" not "config_type"
1765        let serialized = serde_json::to_string(&request).unwrap();
1766        assert!(serialized.contains("\"type\":"));
1767        assert!(!serialized.contains("\"config_type\":"));
1768    }
1769
1770    #[test]
1771    fn test_store_config_response_serialization() {
1772        use crate::runbeam_api::types::StoreConfigModel;
1773
1774        let response = StoreConfigResponse {
1775            success: true,
1776            message: "Configuration stored successfully".to_string(),
1777            data: StoreConfigModel {
1778                id: "01k9npa4tatmwddk66xxpcr2r0".to_string(),
1779                model_type: "gateway".to_string(),
1780                action: "updated".to_string(),
1781            },
1782        };
1783
1784        let json = serde_json::to_string(&response).unwrap();
1785        assert!(json.contains("\"success\":true"));
1786        assert!(json.contains("Configuration stored successfully"));
1787
1788        // Test deserialization
1789        let deserialized: StoreConfigResponse = serde_json::from_str(&json).unwrap();
1790        assert_eq!(deserialized.success, true);
1791        assert_eq!(deserialized.message, "Configuration stored successfully");
1792        assert_eq!(deserialized.data.id, "01k9npa4tatmwddk66xxpcr2r0");
1793    }
1794
1795    #[test]
1796    fn test_acknowledge_changes_response_serialization() {
1797        use crate::runbeam_api::resources::AcknowledgeChangesResponse;
1798
1799        // Test successful acknowledgment
1800        let response = AcknowledgeChangesResponse {
1801            acknowledged: vec![
1802                "change-1".to_string(),
1803                "change-2".to_string(),
1804                "change-3".to_string(),
1805            ],
1806            failed: vec![],
1807        };
1808
1809        let json = serde_json::to_string(&response).unwrap();
1810        assert!(json.contains("\"acknowledged\":"));
1811        assert!(json.contains("\"failed\":"));
1812        assert!(json.contains("change-1"));
1813
1814        // Test deserialization
1815        let deserialized: AcknowledgeChangesResponse = serde_json::from_str(&json).unwrap();
1816        assert_eq!(deserialized.acknowledged.len(), 3);
1817        assert_eq!(deserialized.failed.len(), 0);
1818
1819        // Test partial failure
1820        let response_with_failures = AcknowledgeChangesResponse {
1821            acknowledged: vec!["change-1".to_string()],
1822            failed: vec!["change-2".to_string(), "change-3".to_string()],
1823        };
1824
1825        let json = serde_json::to_string(&response_with_failures).unwrap();
1826        let deserialized: AcknowledgeChangesResponse = serde_json::from_str(&json).unwrap();
1827        assert_eq!(deserialized.acknowledged.len(), 1);
1828        assert_eq!(deserialized.failed.len(), 2);
1829    }
1830
1831    #[test]
1832    fn test_change_status_response_serialization() {
1833        use crate::runbeam_api::resources::{
1834            ChangeAppliedResponse, ChangeFailedResponse, ChangeStatusResponse,
1835        };
1836
1837        // Test ChangeStatusResponse
1838        let response = ChangeStatusResponse {
1839            success: true,
1840            message: "Change marked as applied".to_string(),
1841        };
1842
1843        let json = serde_json::to_string(&response).unwrap();
1844        assert!(json.contains("\"success\":true"));
1845        assert!(json.contains("\"message\":\"Change marked as applied\""));
1846
1847        // Test deserialization
1848        let deserialized: ChangeStatusResponse = serde_json::from_str(&json).unwrap();
1849        assert_eq!(deserialized.success, true);
1850        assert_eq!(deserialized.message, "Change marked as applied");
1851
1852        // Test ChangeAppliedResponse (type alias)
1853        let applied_response: ChangeAppliedResponse = ChangeStatusResponse {
1854            success: true,
1855            message: "Change marked as applied".to_string(),
1856        };
1857
1858        let json = serde_json::to_string(&applied_response).unwrap();
1859        let deserialized: ChangeAppliedResponse = serde_json::from_str(&json).unwrap();
1860        assert_eq!(deserialized.success, true);
1861
1862        // Test ChangeFailedResponse (type alias)
1863        let failed_response: ChangeFailedResponse = ChangeStatusResponse {
1864            success: true,
1865            message: "Change marked as failed".to_string(),
1866        };
1867
1868        let json = serde_json::to_string(&failed_response).unwrap();
1869        let deserialized: ChangeFailedResponse = serde_json::from_str(&json).unwrap();
1870        assert_eq!(deserialized.success, true);
1871        assert_eq!(deserialized.message, "Change marked as failed");
1872    }
1873}