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