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.model.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 response_data = response.json::<StoreConfigResponse>().await.map_err(|e| {
1269            tracing::error!("Failed to parse store config response: {}", e);
1270            ApiError::Parse(format!("Failed to parse response: {}", e))
1271        })?;
1272
1273        tracing::info!(
1274            "Configuration stored successfully: type={}, id={:?}, action={}",
1275            config_type,
1276            id,
1277            response_data.data.model.action
1278        );
1279
1280        Ok(response_data)
1281    }
1282}
1283
1284#[cfg(test)]
1285mod tests {
1286    use super::*;
1287
1288    #[test]
1289    fn test_client_creation() {
1290        let client = RunbeamClient::new("http://example.com");
1291        assert_eq!(client.base_url(), "http://example.com");
1292    }
1293
1294    #[test]
1295    fn test_client_creation_with_string() {
1296        let base_url = String::from("http://example.com");
1297        let client = RunbeamClient::new(base_url);
1298        assert_eq!(client.base_url(), "http://example.com");
1299    }
1300
1301    #[test]
1302    fn test_authorize_request_serialization() {
1303        let request = AuthorizeRequest {
1304            token: "test_token".to_string(),
1305            gateway_code: "gw123".to_string(),
1306            machine_public_key: Some("pubkey123".to_string()),
1307            metadata: None,
1308        };
1309
1310        let json = serde_json::to_string(&request).unwrap();
1311        assert!(json.contains("\"token\":\"test_token\""));
1312        assert!(json.contains("\"gateway_code\":\"gw123\""));
1313        assert!(json.contains("\"machine_public_key\":\"pubkey123\""));
1314    }
1315
1316    #[test]
1317    fn test_authorize_request_serialization_without_optional_fields() {
1318        let request = AuthorizeRequest {
1319            token: "test_token".to_string(),
1320            gateway_code: "gw123".to_string(),
1321            machine_public_key: None,
1322            metadata: None,
1323        };
1324
1325        let json = serde_json::to_string(&request).unwrap();
1326        assert!(json.contains("\"token\":\"test_token\""));
1327        assert!(json.contains("\"gateway_code\":\"gw123\""));
1328        // Should not contain the optional fields
1329        assert!(!json.contains("machine_public_key"));
1330        assert!(!json.contains("metadata"));
1331    }
1332
1333    #[test]
1334    fn test_change_serialization() {
1335        use crate::runbeam_api::resources::Change;
1336
1337        // Test Change with metadata (list view)
1338        let change_metadata = Change {
1339            id: "01JBXXXXXXXXXXXXXXXXXXXXXXXXXX".to_string(),
1340            status: Some("pending".to_string()),
1341            resource_type: "gateway".to_string(),
1342            gateway_id: "01JBXXXXXXXXXXXXXXXXXXXXXXXXXX".to_string(),
1343            pipeline_id: None,
1344            toml_config: None,
1345            metadata: None,
1346            created_at: "2025-01-07T01:00:00+00:00".to_string(),
1347            acknowledged_at: None,
1348            applied_at: None,
1349            failed_at: None,
1350            error_message: None,
1351            error_details: None,
1352        };
1353
1354        let json = serde_json::to_string(&change_metadata).unwrap();
1355        assert!(json.contains("\"id\":\"01JBXXXXXXXXXXXXXXXXXXXXXXXXXX\""));
1356        assert!(json.contains("\"gateway_id\":\"01JBXXXXXXXXXXXXXXXXXXXXXXXXXX\""));
1357        assert!(json.contains("\"type\":\"gateway\""));
1358
1359        // Test deserialization
1360        let deserialized: Change = serde_json::from_str(&json).unwrap();
1361        assert_eq!(deserialized.id, "01JBXXXXXXXXXXXXXXXXXXXXXXXXXX");
1362        assert_eq!(deserialized.status, Some("pending".to_string()));
1363        assert_eq!(deserialized.resource_type, "gateway");
1364
1365        // Test Change with full details (detail view)
1366        let change_detail = Change {
1367            id: "01JBXXXXXXXXXXXXXXXXXXXXXXXXXX".to_string(),
1368            status: Some("applied".to_string()),
1369            resource_type: "gateway".to_string(),
1370            gateway_id: "01JBXXXXXXXXXXXXXXXXXXXXXXXXXX".to_string(),
1371            pipeline_id: None,
1372            toml_config: Some("[proxy]\nname = \"test\"".to_string()),
1373            metadata: Some(serde_json::json!({"gateway_name": "test-gateway"})),
1374            created_at: "2025-01-07T01:00:00+00:00".to_string(),
1375            acknowledged_at: Some("2025-01-07T01:00:05+00:00".to_string()),
1376            applied_at: Some("2025-01-07T01:00:10+00:00".to_string()),
1377            failed_at: None,
1378            error_message: None,
1379            error_details: None,
1380        };
1381
1382        let json = serde_json::to_string(&change_detail).unwrap();
1383        assert!(json.contains("toml_config"));
1384        assert!(json.contains("acknowledged_at"));
1385        assert!(json.contains("applied_at"));
1386
1387        // Test deserialization of detail view
1388        let deserialized: Change = serde_json::from_str(&json).unwrap();
1389        assert!(deserialized.toml_config.is_some());
1390        assert!(deserialized.acknowledged_at.is_some());
1391        assert!(deserialized.applied_at.is_some());
1392    }
1393
1394    #[test]
1395    fn test_acknowledge_changes_request_serialization() {
1396        use crate::runbeam_api::resources::AcknowledgeChangesRequest;
1397
1398        let request = AcknowledgeChangesRequest {
1399            change_ids: vec![
1400                "change-1".to_string(),
1401                "change-2".to_string(),
1402                "change-3".to_string(),
1403            ],
1404        };
1405
1406        let json = serde_json::to_string(&request).unwrap();
1407        assert!(json.contains("\"change_ids\""));
1408        assert!(json.contains("\"change-1\""));
1409        assert!(json.contains("\"change-2\""));
1410        assert!(json.contains("\"change-3\""));
1411
1412        // Test deserialization
1413        let deserialized: AcknowledgeChangesRequest = serde_json::from_str(&json).unwrap();
1414        assert_eq!(deserialized.change_ids.len(), 3);
1415        assert_eq!(deserialized.change_ids[0], "change-1");
1416    }
1417
1418    #[test]
1419    fn test_change_failed_request_serialization() {
1420        use crate::runbeam_api::resources::ChangeFailedRequest;
1421
1422        // Test with details
1423        let request_with_details = ChangeFailedRequest {
1424            error: "Configuration parse error".to_string(),
1425            details: Some(vec![
1426                "Invalid JSON at line 42".to_string(),
1427                "Missing required field 'name'".to_string(),
1428            ]),
1429        };
1430
1431        let json = serde_json::to_string(&request_with_details).unwrap();
1432        assert!(json.contains("\"error\":\"Configuration parse error\""));
1433        assert!(json.contains("\"details\""));
1434        assert!(json.contains("Invalid JSON at line 42"));
1435
1436        // Test without details (should omit the field)
1437        let request_without_details = ChangeFailedRequest {
1438            error: "Unknown error".to_string(),
1439            details: None,
1440        };
1441
1442        let json = serde_json::to_string(&request_without_details).unwrap();
1443        assert!(json.contains("\"error\":\"Unknown error\""));
1444        assert!(!json.contains("\"details\"")); // Should be omitted due to skip_serializing_if
1445
1446        // Test deserialization
1447        let deserialized: ChangeFailedRequest =
1448            serde_json::from_str(&serde_json::to_string(&request_with_details).unwrap()).unwrap();
1449        assert_eq!(deserialized.error, "Configuration parse error");
1450        assert!(deserialized.details.is_some());
1451        assert_eq!(deserialized.details.unwrap().len(), 2);
1452    }
1453
1454    #[test]
1455    fn test_base_url_response_serialization() {
1456        use crate::runbeam_api::resources::BaseUrlResponse;
1457
1458        let response = BaseUrlResponse {
1459            base_url: "https://api.runbeam.io".to_string(),
1460            changes_path: Some("/api/changes".to_string()),
1461            full_url: Some("https://api.runbeam.io/api/changes".to_string()),
1462        };
1463
1464        let json = serde_json::to_string(&response).unwrap();
1465        assert!(json.contains("\"base_url\":\"https://api.runbeam.io\""));
1466
1467        // Test deserialization
1468        let deserialized: BaseUrlResponse = serde_json::from_str(&json).unwrap();
1469        assert_eq!(deserialized.base_url, "https://api.runbeam.io");
1470        assert_eq!(deserialized.changes_path, Some("/api/changes".to_string()));
1471        assert_eq!(
1472            deserialized.full_url,
1473            Some("https://api.runbeam.io/api/changes".to_string())
1474        );
1475    }
1476
1477    #[test]
1478    fn test_store_config_request_serialization_with_id() {
1479        let request = StoreConfigRequest {
1480            config_type: "gateway".to_string(),
1481            id: Some("01k8ek6h9aahhnrv3benret1nn".to_string()),
1482            config: "[proxy]\nid = \"test\"\n".to_string(),
1483        };
1484
1485        let json = serde_json::to_string(&request).unwrap();
1486        // Verify field renaming: config_type -> "type"
1487        assert!(json.contains("\"type\":\"gateway\""));
1488        assert!(json.contains("\"id\":\"01k8ek6h9aahhnrv3benret1nn\""));
1489        assert!(json.contains("\"config\":"));
1490        assert!(json.contains("[proxy]"));
1491
1492        // Test deserialization
1493        let deserialized: StoreConfigRequest = serde_json::from_str(&json).unwrap();
1494        assert_eq!(deserialized.config_type, "gateway");
1495        assert_eq!(
1496            deserialized.id,
1497            Some("01k8ek6h9aahhnrv3benret1nn".to_string())
1498        );
1499    }
1500
1501    #[test]
1502    fn test_store_config_request_serialization_without_id() {
1503        let request = StoreConfigRequest {
1504            config_type: "pipeline".to_string(),
1505            id: None,
1506            config: "[pipeline]\nname = \"test\"\n".to_string(),
1507        };
1508
1509        let json = serde_json::to_string(&request).unwrap();
1510        assert!(json.contains("\"type\":\"pipeline\""));
1511        assert!(json.contains("\"config\":"));
1512        // Should not contain the id field when None
1513        assert!(!json.contains("\"id\""));
1514
1515        // Test deserialization
1516        let deserialized: StoreConfigRequest = serde_json::from_str(&json).unwrap();
1517        assert_eq!(deserialized.config_type, "pipeline");
1518        assert_eq!(deserialized.id, None);
1519    }
1520
1521    #[test]
1522    fn test_store_config_request_field_rename() {
1523        // Test that the "type" JSON field correctly maps to config_type
1524        let json = r#"{"type":"transform","config":"[transform]\nname = \"test\"\n"}"#;
1525        let request: StoreConfigRequest = serde_json::from_str(json).unwrap();
1526        assert_eq!(request.config_type, "transform");
1527        assert_eq!(request.id, None);
1528
1529        // Serialize back and verify it uses "type" not "config_type"
1530        let serialized = serde_json::to_string(&request).unwrap();
1531        assert!(serialized.contains("\"type\":"));
1532        assert!(!serialized.contains("\"config_type\":"));
1533    }
1534
1535    #[test]
1536    fn test_store_config_response_serialization() {
1537        use crate::runbeam_api::types::{StoreConfigModel, StoreConfigResponseData};
1538
1539        let response = StoreConfigResponse {
1540            success: true,
1541            message: "Configuration stored successfully".to_string(),
1542            data: StoreConfigResponseData {
1543                model: StoreConfigModel {
1544                    id: "01k9npa4tatmwddk66xxpcr2r0".to_string(),
1545                    model_type: "gateway".to_string(),
1546                    action: "updated".to_string(),
1547                },
1548            },
1549        };
1550
1551        let json = serde_json::to_string(&response).unwrap();
1552        assert!(json.contains("\"success\":true"));
1553        assert!(json.contains("Configuration stored successfully"));
1554
1555        // Test deserialization
1556        let deserialized: StoreConfigResponse = serde_json::from_str(&json).unwrap();
1557        assert_eq!(deserialized.success, true);
1558        assert_eq!(deserialized.message, "Configuration stored successfully");
1559        assert_eq!(deserialized.data.model.id, "01k9npa4tatmwddk66xxpcr2r0");
1560    }
1561
1562    #[test]
1563    fn test_acknowledge_changes_response_serialization() {
1564        use crate::runbeam_api::resources::AcknowledgeChangesResponse;
1565
1566        // Test successful acknowledgment
1567        let response = AcknowledgeChangesResponse {
1568            acknowledged: vec![
1569                "change-1".to_string(),
1570                "change-2".to_string(),
1571                "change-3".to_string(),
1572            ],
1573            failed: vec![],
1574        };
1575
1576        let json = serde_json::to_string(&response).unwrap();
1577        assert!(json.contains("\"acknowledged\":"));
1578        assert!(json.contains("\"failed\":"));
1579        assert!(json.contains("change-1"));
1580
1581        // Test deserialization
1582        let deserialized: AcknowledgeChangesResponse = serde_json::from_str(&json).unwrap();
1583        assert_eq!(deserialized.acknowledged.len(), 3);
1584        assert_eq!(deserialized.failed.len(), 0);
1585
1586        // Test partial failure
1587        let response_with_failures = AcknowledgeChangesResponse {
1588            acknowledged: vec!["change-1".to_string()],
1589            failed: vec!["change-2".to_string(), "change-3".to_string()],
1590        };
1591
1592        let json = serde_json::to_string(&response_with_failures).unwrap();
1593        let deserialized: AcknowledgeChangesResponse = serde_json::from_str(&json).unwrap();
1594        assert_eq!(deserialized.acknowledged.len(), 1);
1595        assert_eq!(deserialized.failed.len(), 2);
1596    }
1597
1598    #[test]
1599    fn test_change_status_response_serialization() {
1600        use crate::runbeam_api::resources::{
1601            ChangeAppliedResponse, ChangeFailedResponse, ChangeStatusResponse,
1602        };
1603
1604        // Test ChangeStatusResponse
1605        let response = ChangeStatusResponse {
1606            success: true,
1607            message: "Change marked as applied".to_string(),
1608        };
1609
1610        let json = serde_json::to_string(&response).unwrap();
1611        assert!(json.contains("\"success\":true"));
1612        assert!(json.contains("\"message\":\"Change marked as applied\""));
1613
1614        // Test deserialization
1615        let deserialized: ChangeStatusResponse = serde_json::from_str(&json).unwrap();
1616        assert_eq!(deserialized.success, true);
1617        assert_eq!(deserialized.message, "Change marked as applied");
1618
1619        // Test ChangeAppliedResponse (type alias)
1620        let applied_response: ChangeAppliedResponse = ChangeStatusResponse {
1621            success: true,
1622            message: "Change marked as applied".to_string(),
1623        };
1624
1625        let json = serde_json::to_string(&applied_response).unwrap();
1626        let deserialized: ChangeAppliedResponse = serde_json::from_str(&json).unwrap();
1627        assert_eq!(deserialized.success, true);
1628
1629        // Test ChangeFailedResponse (type alias)
1630        let failed_response: ChangeFailedResponse = ChangeStatusResponse {
1631            success: true,
1632            message: "Change marked as failed".to_string(),
1633        };
1634
1635        let json = serde_json::to_string(&failed_response).unwrap();
1636        let deserialized: ChangeFailedResponse = serde_json::from_str(&json).unwrap();
1637        assert_eq!(deserialized.success, true);
1638        assert_eq!(deserialized.message, "Change marked as failed");
1639    }
1640}