runbeam_sdk/runbeam_api/
client.rs

1use crate::runbeam_api::types::{ApiError, AuthorizeResponse, RunbeamError};
2use serde::Serialize;
3
4/// HTTP client for Runbeam Cloud API
5///
6/// This client handles all communication with the Runbeam Cloud control plane,
7/// including gateway authorization and future component loading.
8#[derive(Debug, Clone)]
9pub struct RunbeamClient {
10    /// Base URL for the Runbeam Cloud API (from JWT iss claim)
11    base_url: String,
12    /// HTTP client for making requests
13    client: reqwest::Client,
14}
15
16/// Request payload for gateway authorization
17#[derive(Debug, Serialize)]
18struct AuthorizeRequest {
19    /// JWT token from the user (will be sent in body per Laravel API spec)
20    token: String,
21    /// Gateway code (instance ID)
22    gateway_code: String,
23    /// Optional machine public key for secure communication
24    #[serde(skip_serializing_if = "Option::is_none")]
25    machine_public_key: Option<String>,
26    /// Optional metadata about the gateway (array of strings per v1.1 API spec)
27    #[serde(skip_serializing_if = "Option::is_none")]
28    metadata: Option<Vec<String>>,
29}
30
31impl RunbeamClient {
32    /// Create a new Runbeam Cloud API client
33    ///
34    /// # Arguments
35    ///
36    /// * `base_url` - The Runbeam Cloud API base URL (extracted from JWT iss claim)
37    ///
38    /// # Example
39    ///
40    /// ```no_run
41    /// use runbeam_sdk::RunbeamClient;
42    ///
43    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
44    /// ```
45    pub fn new(base_url: impl Into<String>) -> Self {
46        let base_url = base_url.into();
47        tracing::debug!("Creating RunbeamClient with base URL: {}", base_url);
48
49        Self {
50            base_url,
51            client: reqwest::Client::new(),
52        }
53    }
54
55    /// Authorize a gateway and obtain a machine-scoped token
56    ///
57    /// This method exchanges a user authentication token (either JWT or Laravel Sanctum)
58    /// for a machine-scoped token that the gateway can use for autonomous API access.
59    /// The machine token has a 30-day expiry (configured server-side).
60    ///
61    /// # Authentication
62    ///
63    /// This method accepts both JWT tokens and Laravel Sanctum API tokens:
64    /// - **JWT tokens**: Validated locally with RS256 signature verification (legacy behavior)
65    /// - **Sanctum tokens**: Passed directly to server for validation (format: `{id}|{token}`)
66    ///
67    /// The token is passed to the Runbeam Cloud API in both the Authorization header
68    /// and request body, where final validation and authorization occurs.
69    ///
70    /// # Arguments
71    ///
72    /// * `user_token` - The user's JWT or Sanctum API token from CLI authentication
73    /// * `gateway_code` - The gateway instance ID
74    /// * `machine_public_key` - Optional public key for secure communication
75    /// * `metadata` - Optional metadata about the gateway (array of strings)
76    ///
77    /// # Returns
78    ///
79    /// Returns `Ok(AuthorizeResponse)` with machine token and gateway details,
80    /// or `Err(RunbeamError)` if authorization fails.
81    ///
82    /// # Example
83    ///
84    /// ```no_run
85    /// use runbeam_sdk::RunbeamClient;
86    ///
87    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
88    /// let client = RunbeamClient::new("http://runbeam.lndo.site");
89    ///
90    /// // Using JWT token
91    /// let response = client.authorize_gateway(
92    ///     "eyJhbGci...",
93    ///     "gateway-123",
94    ///     None,
95    ///     None
96    /// ).await?;
97    ///
98    /// // Using Sanctum token
99    /// let response = client.authorize_gateway(
100    ///     "1|abc123def456...",
101    ///     "gateway-123",
102    ///     None,
103    ///     None
104    /// ).await?;
105    ///
106    /// println!("Machine token: {}", response.machine_token);
107    /// println!("Expires at: {}", response.expires_at);
108    /// # Ok(())
109    /// # }
110    /// ```
111    pub async fn authorize_gateway(
112        &self,
113        user_token: impl Into<String>,
114        gateway_code: impl Into<String>,
115        machine_public_key: Option<String>,
116        metadata: Option<Vec<String>>,
117    ) -> Result<AuthorizeResponse, RunbeamError> {
118        let user_token = user_token.into();
119        let gateway_code = gateway_code.into();
120
121        tracing::info!(
122            "Authorizing gateway with Runbeam Cloud: gateway_code={}",
123            gateway_code
124        );
125
126        // Construct the authorization endpoint URL
127        let url = format!("{}/api/harmony/authorize", self.base_url);
128
129        // Build request payload
130        let payload = AuthorizeRequest {
131            token: user_token.clone(),
132            gateway_code: gateway_code.clone(),
133            machine_public_key,
134            metadata,
135        };
136
137        tracing::debug!("Sending authorization request to: {}", url);
138
139        // Make the request
140        let response = self
141            .client
142            .post(&url)
143            .header("Authorization", format!("Bearer {}", user_token))
144            .header("Content-Type", "application/json")
145            .json(&payload)
146            .send()
147            .await
148            .map_err(|e| {
149                tracing::error!("Failed to send authorization request: {}", e);
150                ApiError::from(e)
151            })?;
152
153        let status = response.status();
154        tracing::debug!("Received response with status: {}", status);
155
156        // Handle error responses
157        if !status.is_success() {
158            let error_body = response
159                .text()
160                .await
161                .unwrap_or_else(|_| "Unknown error".to_string());
162
163            tracing::error!(
164                "Authorization failed: HTTP {} - {}",
165                status.as_u16(),
166                error_body
167            );
168
169            return Err(RunbeamError::Api(ApiError::Http {
170                status: status.as_u16(),
171                message: error_body,
172            }));
173        }
174
175        // Parse successful response
176        let auth_response: AuthorizeResponse = response.json().await.map_err(|e| {
177            tracing::error!("Failed to parse authorization response: {}", e);
178            ApiError::Parse(format!("Failed to parse response JSON: {}", e))
179        })?;
180
181        tracing::info!(
182            "Gateway authorized successfully: gateway_id={}, expires_at={}",
183            auth_response.gateway.id,
184            auth_response.expires_at
185        );
186
187        tracing::debug!(
188            "Machine token length: {}",
189            auth_response.machine_token.len()
190        );
191        tracing::debug!("Gateway abilities: {:?}", auth_response.abilities);
192
193        Ok(auth_response)
194    }
195
196    /// Get the base URL for this client
197    pub fn base_url(&self) -> &str {
198        &self.base_url
199    }
200
201    /// List all gateways for the authenticated team
202    ///
203    /// Returns a paginated list of gateways.
204    ///
205    /// # Authentication
206    ///
207    /// Accepts either JWT tokens or Laravel Sanctum API tokens. The token is passed
208    /// to the server for validation without local verification.
209    ///
210    /// # Arguments
211    ///
212    /// * `token` - JWT or Sanctum API token for authentication
213    pub async fn list_gateways(
214        &self,
215        token: impl Into<String>,
216    ) -> Result<
217        crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Gateway>,
218        RunbeamError,
219    > {
220        let url = format!("{}/api/gateways", self.base_url);
221
222        let response = self
223            .client
224            .get(&url)
225            .header("Authorization", format!("Bearer {}", token.into()))
226            .send()
227            .await
228            .map_err(ApiError::from)?;
229
230        if !response.status().is_success() {
231            let status = response.status();
232            let error_body = response
233                .text()
234                .await
235                .unwrap_or_else(|_| "Unknown error".to_string());
236            return Err(RunbeamError::Api(ApiError::Http {
237                status: status.as_u16(),
238                message: error_body,
239            }));
240        }
241
242        response.json().await.map_err(|e| {
243            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
244        })
245    }
246
247    /// Get a specific gateway by ID or code
248    ///
249    /// # Authentication
250    ///
251    /// Accepts JWT tokens, Sanctum API tokens, or machine tokens. The token is passed
252    /// to the server for validation without local verification.
253    ///
254    /// # Arguments
255    ///
256    /// * `token` - JWT, Sanctum API token, or machine token for authentication
257    /// * `gateway_id` - The gateway ID or code
258    pub async fn get_gateway(
259        &self,
260        token: impl Into<String>,
261        gateway_id: impl Into<String>,
262    ) -> Result<
263        crate::runbeam_api::resources::ResourceResponse<crate::runbeam_api::resources::Gateway>,
264        RunbeamError,
265    > {
266        let url = format!("{}/api/gateways/{}", self.base_url, gateway_id.into());
267
268        let response = self
269            .client
270            .get(&url)
271            .header("Authorization", format!("Bearer {}", token.into()))
272            .send()
273            .await
274            .map_err(ApiError::from)?;
275
276        if !response.status().is_success() {
277            let status = response.status();
278            let error_body = response
279                .text()
280                .await
281                .unwrap_or_else(|_| "Unknown error".to_string());
282            return Err(RunbeamError::Api(ApiError::Http {
283                status: status.as_u16(),
284                message: error_body,
285            }));
286        }
287
288        response.json().await.map_err(|e| {
289            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
290        })
291    }
292
293    /// List all services for the authenticated team
294    ///
295    /// Returns a paginated list of services across all gateways.
296    ///
297    /// # Authentication
298    ///
299    /// Accepts either JWT tokens or Laravel Sanctum API tokens. The token is passed
300    /// to the server for validation without local verification.
301    ///
302    /// # Arguments
303    ///
304    /// * `token` - JWT or Sanctum API token for authentication
305    pub async fn list_services(
306        &self,
307        token: impl Into<String>,
308    ) -> Result<
309        crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Service>,
310        RunbeamError,
311    > {
312        let url = format!("{}/api/services", self.base_url);
313
314        let response = self
315            .client
316            .get(&url)
317            .header("Authorization", format!("Bearer {}", token.into()))
318            .send()
319            .await
320            .map_err(ApiError::from)?;
321
322        if !response.status().is_success() {
323            let status = response.status();
324            let error_body = response
325                .text()
326                .await
327                .unwrap_or_else(|_| "Unknown error".to_string());
328            return Err(RunbeamError::Api(ApiError::Http {
329                status: status.as_u16(),
330                message: error_body,
331            }));
332        }
333
334        response.json().await.map_err(|e| {
335            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
336        })
337    }
338
339    /// Get a specific service by ID
340    ///
341    /// # Authentication
342    ///
343    /// Accepts JWT tokens, Sanctum API tokens, or machine tokens. The token is passed
344    /// to the server for validation without local verification.
345    ///
346    /// # Arguments
347    ///
348    /// * `token` - JWT, Sanctum API token, or machine token for authentication
349    /// * `service_id` - The service ID
350    pub async fn get_service(
351        &self,
352        token: impl Into<String>,
353        service_id: impl Into<String>,
354    ) -> Result<
355        crate::runbeam_api::resources::ResourceResponse<crate::runbeam_api::resources::Service>,
356        RunbeamError,
357    > {
358        let url = format!("{}/api/services/{}", self.base_url, service_id.into());
359
360        let response = self
361            .client
362            .get(&url)
363            .header("Authorization", format!("Bearer {}", token.into()))
364            .send()
365            .await
366            .map_err(ApiError::from)?;
367
368        if !response.status().is_success() {
369            let status = response.status();
370            let error_body = response
371                .text()
372                .await
373                .unwrap_or_else(|_| "Unknown error".to_string());
374            return Err(RunbeamError::Api(ApiError::Http {
375                status: status.as_u16(),
376                message: error_body,
377            }));
378        }
379
380        response.json().await.map_err(|e| {
381            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
382        })
383    }
384
385    /// List all endpoints for the authenticated team
386    ///
387    /// # Authentication
388    ///
389    /// Accepts either JWT tokens or Laravel Sanctum API tokens. The token is passed
390    /// to the server for validation without local verification.
391    ///
392    /// # Arguments
393    ///
394    /// * `token` - JWT or Sanctum API token for authentication
395    pub async fn list_endpoints(
396        &self,
397        token: impl Into<String>,
398    ) -> Result<
399        crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Endpoint>,
400        RunbeamError,
401    > {
402        let url = format!("{}/api/endpoints", self.base_url);
403
404        let response = self
405            .client
406            .get(&url)
407            .header("Authorization", format!("Bearer {}", token.into()))
408            .send()
409            .await
410            .map_err(ApiError::from)?;
411
412        if !response.status().is_success() {
413            let status = response.status();
414            let error_body = response
415                .text()
416                .await
417                .unwrap_or_else(|_| "Unknown error".to_string());
418            return Err(RunbeamError::Api(ApiError::Http {
419                status: status.as_u16(),
420                message: error_body,
421            }));
422        }
423
424        response.json().await.map_err(|e| {
425            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
426        })
427    }
428
429    /// List all backends for the authenticated team
430    ///
431    /// # Authentication
432    ///
433    /// Accepts either JWT tokens or Laravel Sanctum API tokens. The token is passed
434    /// to the server for validation without local verification.
435    ///
436    /// # Arguments
437    ///
438    /// * `token` - JWT or Sanctum API token for authentication
439    pub async fn list_backends(
440        &self,
441        token: impl Into<String>,
442    ) -> Result<
443        crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Backend>,
444        RunbeamError,
445    > {
446        let url = format!("{}/api/backends", self.base_url);
447
448        let response = self
449            .client
450            .get(&url)
451            .header("Authorization", format!("Bearer {}", token.into()))
452            .send()
453            .await
454            .map_err(ApiError::from)?;
455
456        if !response.status().is_success() {
457            let status = response.status();
458            let error_body = response
459                .text()
460                .await
461                .unwrap_or_else(|_| "Unknown error".to_string());
462            return Err(RunbeamError::Api(ApiError::Http {
463                status: status.as_u16(),
464                message: error_body,
465            }));
466        }
467
468        response.json().await.map_err(|e| {
469            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
470        })
471    }
472
473    /// List all pipelines for the authenticated team
474    ///
475    /// # Authentication
476    ///
477    /// Accepts either JWT tokens or Laravel Sanctum API tokens. The token is passed
478    /// to the server for validation without local verification.
479    ///
480    /// # Arguments
481    ///
482    /// * `token` - JWT or Sanctum API token for authentication
483    pub async fn list_pipelines(
484        &self,
485        token: impl Into<String>,
486    ) -> Result<
487        crate::runbeam_api::resources::PaginatedResponse<crate::runbeam_api::resources::Pipeline>,
488        RunbeamError,
489    > {
490        let url = format!("{}/api/pipelines", self.base_url);
491
492        let response = self
493            .client
494            .get(&url)
495            .header("Authorization", format!("Bearer {}", token.into()))
496            .send()
497            .await
498            .map_err(ApiError::from)?;
499
500        if !response.status().is_success() {
501            let status = response.status();
502            let error_body = response
503                .text()
504                .await
505                .unwrap_or_else(|_| "Unknown error".to_string());
506            return Err(RunbeamError::Api(ApiError::Http {
507                status: status.as_u16(),
508                message: error_body,
509            }));
510        }
511
512        response.json().await.map_err(|e| {
513            RunbeamError::Api(ApiError::Parse(format!("Failed to parse response: {}", e)))
514        })
515    }
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521
522    #[test]
523    fn test_client_creation() {
524        let client = RunbeamClient::new("http://example.com");
525        assert_eq!(client.base_url(), "http://example.com");
526    }
527
528    #[test]
529    fn test_client_creation_with_string() {
530        let base_url = String::from("http://example.com");
531        let client = RunbeamClient::new(base_url);
532        assert_eq!(client.base_url(), "http://example.com");
533    }
534
535    #[test]
536    fn test_authorize_request_serialization() {
537        let request = AuthorizeRequest {
538            token: "test_token".to_string(),
539            gateway_code: "gw123".to_string(),
540            machine_public_key: Some("pubkey123".to_string()),
541            metadata: None,
542        };
543
544        let json = serde_json::to_string(&request).unwrap();
545        assert!(json.contains("\"token\":\"test_token\""));
546        assert!(json.contains("\"gateway_code\":\"gw123\""));
547        assert!(json.contains("\"machine_public_key\":\"pubkey123\""));
548    }
549
550    #[test]
551    fn test_authorize_request_serialization_without_optional_fields() {
552        let request = AuthorizeRequest {
553            token: "test_token".to_string(),
554            gateway_code: "gw123".to_string(),
555            machine_public_key: None,
556            metadata: None,
557        };
558
559        let json = serde_json::to_string(&request).unwrap();
560        assert!(json.contains("\"token\":\"test_token\""));
561        assert!(json.contains("\"gateway_code\":\"gw123\""));
562        // Should not contain the optional fields
563        assert!(!json.contains("machine_public_key"));
564        assert!(!json.contains("metadata"));
565    }
566}