runbeam_sdk/runbeam_api/
types.rs

1use serde::{Deserialize, Serialize};
2use std::fmt;
3
4/// Runbeam API error type
5///
6/// Represents all possible errors that can occur when interacting with
7/// the Runbeam Cloud API or performing related operations.
8#[derive(Debug)]
9pub enum RunbeamError {
10    /// JWT validation failed
11    JwtValidation(String),
12    /// API request failed (network, HTTP, or response parsing error)
13    Api(ApiError),
14    /// Token storage operation failed
15    Storage(crate::storage::StorageError),
16    /// Configuration error
17    Config(String),
18    /// TOML validation failed
19    Validation(crate::validation::ValidationError),
20}
21
22impl fmt::Display for RunbeamError {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        match self {
25            RunbeamError::JwtValidation(msg) => write!(f, "JWT validation failed: {}", msg),
26            RunbeamError::Api(err) => write!(f, "API error: {}", err),
27            RunbeamError::Storage(err) => write!(f, "Storage error: {}", err),
28            RunbeamError::Config(msg) => write!(f, "Configuration error: {}", msg),
29            RunbeamError::Validation(err) => write!(f, "Validation error: {}", err),
30        }
31    }
32}
33
34impl std::error::Error for RunbeamError {}
35
36impl From<ApiError> for RunbeamError {
37    fn from(err: ApiError) -> Self {
38        RunbeamError::Api(err)
39    }
40}
41
42impl From<crate::storage::StorageError> for RunbeamError {
43    fn from(err: crate::storage::StorageError) -> Self {
44        RunbeamError::Storage(err)
45    }
46}
47
48impl From<jsonwebtoken::errors::Error> for RunbeamError {
49    fn from(err: jsonwebtoken::errors::Error) -> Self {
50        RunbeamError::JwtValidation(err.to_string())
51    }
52}
53
54impl From<crate::validation::ValidationError> for RunbeamError {
55    fn from(err: crate::validation::ValidationError) -> Self {
56        RunbeamError::Validation(err)
57    }
58}
59
60/// API-specific errors
61#[derive(Debug)]
62pub enum ApiError {
63    /// Network error (connection, timeout, etc.)
64    Network(String),
65    /// HTTP error with status code
66    Http { status: u16, message: String },
67    /// Failed to parse response
68    Parse(String),
69    /// Request building failed
70    Request(String),
71}
72
73impl fmt::Display for ApiError {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        match self {
76            ApiError::Network(msg) => write!(f, "Network error: {}", msg),
77            ApiError::Http { status, message } => {
78                write!(f, "HTTP {} error: {}", status, message)
79            }
80            ApiError::Parse(msg) => write!(f, "Parse error: {}", msg),
81            ApiError::Request(msg) => write!(f, "Request error: {}", msg),
82        }
83    }
84}
85
86impl std::error::Error for ApiError {}
87
88impl From<reqwest::Error> for ApiError {
89    fn from(err: reqwest::Error) -> Self {
90        if err.is_timeout() {
91            ApiError::Network("Request timeout".to_string())
92        } else if err.is_connect() {
93            ApiError::Network(format!("Connection failed: {}", err))
94        } else if let Some(status) = err.status() {
95            ApiError::Http {
96                status: status.as_u16(),
97                message: err.to_string(),
98            }
99        } else {
100            ApiError::Network(err.to_string())
101        }
102    }
103}
104
105/// User information from JWT claims
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct UserInfo {
108    pub id: String,
109    pub email: String,
110    pub name: String,
111}
112
113/// User authentication token (JWT)
114///
115/// This token is used for authenticating user actions with the Runbeam Cloud API.
116/// It has a shorter lifespan than machine tokens and is typically issued after
117/// a user successfully logs in via the browser-based OAuth flow.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct UserToken {
120    /// JWT token for API authentication
121    pub token: String,
122    /// Token expiration timestamp (seconds since Unix epoch)
123    #[serde(default)]
124    pub expires_at: Option<i64>,
125    /// User information from JWT claims
126    #[serde(default)]
127    pub user: Option<UserInfo>,
128}
129
130impl UserToken {
131    /// Create a new user token
132    pub fn new(token: String, expires_at: Option<i64>, user: Option<UserInfo>) -> Self {
133        Self {
134            token,
135            expires_at,
136            user,
137        }
138    }
139}
140
141/// Team information from JWT claims
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct TeamInfo {
144    pub id: String,
145    pub name: String,
146}
147
148/// Gateway information returned from authorize endpoint
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct GatewayInfo {
151    pub id: String,
152    pub code: String,
153    pub name: String,
154    #[serde(default)]
155    pub authorized_by: Option<AuthorizedBy>,
156}
157
158/// User who authorized the gateway
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct AuthorizedBy {
161    pub id: String,
162    pub name: String,
163    pub email: String,
164}
165
166/// Response from Runbeam Cloud authorize endpoint
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct AuthorizeResponse {
169    pub machine_token: String,
170    pub expires_in: f64,
171    pub expires_at: String,
172    pub gateway: GatewayInfo,
173    #[serde(default)]
174    pub abilities: Vec<String>,
175}
176
177/// Request payload for storing/updating Harmony configuration
178///
179/// This is used by the `harmony.update` endpoint to send TOML configuration
180/// from Harmony instances back to Runbeam Cloud for storage as database models.
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct StoreConfigRequest {
183    /// Type of configuration being stored ("gateway", "pipeline", or "transform")
184    #[serde(rename = "type")]
185    pub config_type: String,
186    /// Optional ID for updating existing resources (omitted for creates)
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub id: Option<String>,
189    /// TOML configuration content
190    pub config: String,
191}
192
193/// Response from storing/updating Harmony configuration
194///
195/// The API returns UpdateSuccessResource format.
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct StoreConfigResponse {
198    /// Success flag
199    pub success: bool,
200    /// Success message
201    pub message: String,
202    /// Response data with model and change info
203    pub data: StoreConfigModel,
204}
205
206/// Model information from store config response
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct StoreConfigModel {
209    /// Model ID (ULID)
210    pub id: String,
211    /// Model type ("gateway", "pipeline", "transform")
212    #[serde(rename = "type")]
213    pub model_type: String,
214    /// Action taken ("created", "updated")
215    pub action: String,
216}
217
218/// Mesh information returned from Runbeam Cloud API
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct MeshInfo {
221    /// Unique mesh identifier (ULID)
222    pub id: String,
223    /// Human-readable mesh name
224    pub name: String,
225    /// Protocol type for mesh communication (http, http3)
226    #[serde(rename = "type")]
227    pub mesh_type: String,
228    /// Mesh provider (local, runbeam)
229    pub provider: String,
230    /// Authentication type for mesh members (currently only "jwt")
231    #[serde(default = "default_auth_type")]
232    pub auth_type: String,
233    /// JWT secret for HS256 symmetric key authentication (local provider)
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub jwt_secret: Option<String>,
236    /// Path to RSA private key (PEM) for RS256 JWT signing (local provider)
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub jwt_private_key_path: Option<String>,
239    /// Path to RSA public key (PEM) for RS256 JWT verification (local provider)
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub jwt_public_key_path: Option<String>,
242    /// List of ingress definition names
243    #[serde(default)]
244    pub ingress: Vec<String>,
245    /// List of egress definition names
246    #[serde(default)]
247    pub egress: Vec<String>,
248    /// Whether the mesh is enabled
249    #[serde(default = "default_true")]
250    pub enabled: bool,
251    /// Optional description
252    #[serde(default)]
253    pub description: Option<String>,
254}
255
256/// Mesh ingress information - allows other mesh members to send requests
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct MeshIngressInfo {
259    /// Unique ingress identifier (ULID)
260    pub id: String,
261    /// Human-readable ingress name
262    pub name: String,
263    /// Protocol type for incoming mesh requests (http, http3)
264    #[serde(rename = "type")]
265    pub ingress_type: String,
266    /// Pipeline name that owns this ingress (required)
267    pub pipeline: String,
268    /// Mode: 'default' allows all requests, 'mesh' requires valid mesh authentication
269    #[serde(default = "default_mode")]
270    pub mode: String,
271    /// Optional endpoint override. If omitted, the first endpoint in the pipeline is used.
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub endpoint: Option<String>,
274    /// List of URLs that map to this ingress
275    #[serde(default)]
276    pub urls: Vec<String>,
277    /// Whether the ingress is enabled
278    #[serde(default = "default_true")]
279    pub enabled: bool,
280    /// Optional description
281    #[serde(default)]
282    pub description: Option<String>,
283}
284
285/// Mesh egress information - allows sending requests to other mesh members
286#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct MeshEgressInfo {
288    /// Unique egress identifier (ULID)
289    pub id: String,
290    /// Human-readable egress name
291    pub name: String,
292    /// Protocol type for outgoing mesh requests (http, http3)
293    #[serde(rename = "type")]
294    pub egress_type: String,
295    /// Pipeline name that owns this egress (required)
296    pub pipeline: String,
297    /// Mode: 'default' allows all destinations, 'mesh' requires destination to match a mesh ingress
298    #[serde(default = "default_mode")]
299    pub mode: String,
300    /// Optional backend override. If omitted, the first backend in the pipeline is used.
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub backend: Option<String>,
303    /// Whether the egress is enabled
304    #[serde(default = "default_true")]
305    pub enabled: bool,
306    /// Optional description
307    #[serde(default)]
308    pub description: Option<String>,
309}
310
311fn default_true() -> bool {
312    true
313}
314
315fn default_mode() -> String {
316    "default".to_string()
317}
318
319fn default_auth_type() -> String {
320    "jwt".to_string()
321}
322
323fn default_poll_interval() -> u32 {
324    30
325}
326
327// ========================================================================================
328// RESOURCE RESOLUTION TYPES
329// ========================================================================================
330
331/// Response from resolving a resource reference
332#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct ResolveResourceResponse {
334    /// The resolved resource data
335    pub data: ResolvedResource,
336    /// Resolution metadata
337    pub meta: ResolutionMeta,
338}
339
340/// Metadata about the resolution
341#[derive(Debug, Clone, Serialize, Deserialize)]
342pub struct ResolutionMeta {
343    /// Provider that resolved this resource
344    pub provider: String,
345    /// When the resolution occurred
346    pub resolved_at: String,
347}
348
349/// A resolved resource (type varies based on resource type)
350#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct ResolvedResource {
352    /// Resource type (ingress, egress, pipeline, etc.)
353    #[serde(rename = "type")]
354    pub resource_type: String,
355    /// Resource ID (ULID)
356    pub id: String,
357    /// Resource name
358    pub name: String,
359    /// Team ID
360    #[serde(default)]
361    pub team_id: Option<String>,
362    /// Whether the resource is enabled
363    #[serde(default = "default_true")]
364    pub enabled: bool,
365    /// Gateway ID (for gateway-scoped resources)
366    #[serde(default)]
367    pub gateway_id: Option<String>,
368    /// Pipeline ID (for pipeline-scoped resources)
369    #[serde(default)]
370    pub pipeline_id: Option<String>,
371    /// Mesh ID (for mesh ingress/egress)
372    #[serde(default)]
373    pub mesh_id: Option<String>,
374    /// URLs (for ingress resources)
375    #[serde(default)]
376    pub urls: Vec<String>,
377    /// Protocol (http, http3, etc.)
378    #[serde(default)]
379    pub protocol: Option<String>,
380    /// Mode (default, mesh)
381    #[serde(default)]
382    pub mode: Option<String>,
383    /// Backend ID (for egress resources)
384    #[serde(default)]
385    pub backend_id: Option<String>,
386    /// Service ID (for endpoints/backends)
387    #[serde(default)]
388    pub service_id: Option<String>,
389    /// Endpoint ID (for ingress resources)
390    #[serde(default)]
391    pub endpoint_id: Option<String>,
392    /// Description
393    #[serde(default)]
394    pub description: Option<String>,
395    /// Provider (for mesh resources)
396    #[serde(default)]
397    pub provider: Option<String>,
398    /// Auth type (for mesh resources)
399    #[serde(default)]
400    pub auth_type: Option<String>,
401}
402
403// ========================================================================================
404// PROVIDER TYPES
405// ========================================================================================
406
407/// Provider configuration for resource resolution
408///
409/// Providers define how resources are resolved - either locally from config files
410/// or remotely from a provider API (e.g., Runbeam Cloud).
411#[derive(Debug, Clone, Serialize, Deserialize)]
412pub struct ProviderConfig {
413    /// Base URL for provider API. Required for remote providers, omitted for 'local'.
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    pub api: Option<String>,
416    /// Whether this provider is active
417    #[serde(default = "default_true")]
418    pub enabled: bool,
419    /// Polling interval in seconds for change detection
420    #[serde(default = "default_poll_interval")]
421    pub poll_interval_secs: u32,
422}
423
424impl Default for ProviderConfig {
425    fn default() -> Self {
426        Self {
427            api: None,
428            enabled: true,
429            poll_interval_secs: 30,
430        }
431    }
432}
433
434/// Type of resource being referenced
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
436#[serde(rename_all = "lowercase")]
437pub enum ResourceType {
438    Ingress,
439    Egress,
440    Pipeline,
441    Endpoint,
442    Backend,
443    Mesh,
444}
445
446impl fmt::Display for ResourceType {
447    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
448        match self {
449            ResourceType::Ingress => write!(f, "ingress"),
450            ResourceType::Egress => write!(f, "egress"),
451            ResourceType::Pipeline => write!(f, "pipeline"),
452            ResourceType::Endpoint => write!(f, "endpoint"),
453            ResourceType::Backend => write!(f, "backend"),
454            ResourceType::Mesh => write!(f, "mesh"),
455        }
456    }
457}
458
459impl std::str::FromStr for ResourceType {
460    type Err = String;
461
462    fn from_str(s: &str) -> Result<Self, Self::Err> {
463        match s.to_lowercase().as_str() {
464            "ingress" => Ok(ResourceType::Ingress),
465            "egress" => Ok(ResourceType::Egress),
466            "pipeline" => Ok(ResourceType::Pipeline),
467            "endpoint" => Ok(ResourceType::Endpoint),
468            "backend" => Ok(ResourceType::Backend),
469            "mesh" => Ok(ResourceType::Mesh),
470            _ => Err(format!("Unknown resource type: {}", s)),
471        }
472    }
473}
474
475/// How to look up the resource
476#[derive(Debug, Clone, PartialEq, Eq)]
477pub enum LookupBy {
478    /// Lookup by ULID
479    Id(String),
480    /// Lookup by name
481    Name(String),
482}
483
484/// Parsed resource reference
485///
486/// Supports multiple formats:
487/// - `name` -> local.name.{name}
488/// - `local.name.{name}` -> explicit local lookup
489/// - `{provider}.id.{id}` -> provider-wide ID lookup
490/// - `{provider}.{team}.id.{id}` -> team-scoped ID lookup  
491/// - `{provider}.{team}.{type}.name.{name}` -> full path lookup
492/// - `{provider}.{team}.{type}.id.{id}` -> full path lookup by ID
493#[derive(Debug, Clone, PartialEq, Eq)]
494pub struct ResourceReference {
495    /// Provider name (e.g., "local", "runbeam")
496    pub provider: String,
497    /// Optional team identifier
498    pub team: Option<String>,
499    /// Optional resource type
500    pub resource_type: Option<ResourceType>,
501    /// How to look up the resource
502    pub lookup: LookupBy,
503}
504
505impl ResourceReference {
506    /// Parse a resource reference string
507    ///
508    /// # Examples
509    /// ```
510    /// use runbeam_sdk::runbeam_api::types::ResourceReference;
511    ///
512    /// // Bare name -> local.name.{name}
513    /// let r = ResourceReference::parse("my_ingress").unwrap();
514    /// assert_eq!(r.provider, "local");
515    ///
516    /// // Full path
517    /// let r = ResourceReference::parse("runbeam.acme.ingress.name.patient_api").unwrap();
518    /// assert_eq!(r.provider, "runbeam");
519    /// ```
520    pub fn parse(input: &str) -> Result<Self, String> {
521        let parts: Vec<&str> = input.split('.').collect();
522
523        match parts.len() {
524            // Bare name: "my_ingress" -> local.name.my_ingress
525            1 => Ok(ResourceReference {
526                provider: "local".to_string(),
527                team: None,
528                resource_type: None,
529                lookup: LookupBy::Name(parts[0].to_string()),
530            }),
531
532            // "local.name.{name}" or "{provider}.id.{id}"
533            3 => {
534                let provider = parts[0];
535                match parts[1] {
536                    "name" => Ok(ResourceReference {
537                        provider: provider.to_string(),
538                        team: None,
539                        resource_type: None,
540                        lookup: LookupBy::Name(parts[2].to_string()),
541                    }),
542                    "id" => Ok(ResourceReference {
543                        provider: provider.to_string(),
544                        team: None,
545                        resource_type: None,
546                        lookup: LookupBy::Id(parts[2].to_string()),
547                    }),
548                    _ => Err(format!(
549                        "Invalid reference format: expected 'name' or 'id', got '{}'",
550                        parts[1]
551                    )),
552                }
553            }
554
555            // "{provider}.{team}.id.{id}"
556            4 => {
557                let provider = parts[0];
558                let team = parts[1];
559                match parts[2] {
560                    "id" => Ok(ResourceReference {
561                        provider: provider.to_string(),
562                        team: Some(team.to_string()),
563                        resource_type: None,
564                        lookup: LookupBy::Id(parts[3].to_string()),
565                    }),
566                    _ => Err(format!(
567                        "Invalid reference format: expected 'id' at position 2, got '{}'",
568                        parts[2]
569                    )),
570                }
571            }
572
573            // "{provider}.{team}.{type}.name.{name}" or "{provider}.{team}.{type}.id.{id}"
574            5 => {
575                let provider = parts[0];
576                let team = parts[1];
577                let resource_type: ResourceType = parts[2].parse()?;
578                match parts[3] {
579                    "name" => Ok(ResourceReference {
580                        provider: provider.to_string(),
581                        team: Some(team.to_string()),
582                        resource_type: Some(resource_type),
583                        lookup: LookupBy::Name(parts[4].to_string()),
584                    }),
585                    "id" => Ok(ResourceReference {
586                        provider: provider.to_string(),
587                        team: Some(team.to_string()),
588                        resource_type: Some(resource_type),
589                        lookup: LookupBy::Id(parts[4].to_string()),
590                    }),
591                    _ => Err(format!(
592                        "Invalid reference format: expected 'name' or 'id', got '{}'",
593                        parts[3]
594                    )),
595                }
596            }
597
598            _ => Err(format!(
599                "Invalid reference format: unexpected number of parts ({})",
600                parts.len()
601            )),
602        }
603    }
604
605    /// Check if this reference is for local resolution only
606    pub fn is_local(&self) -> bool {
607        self.provider == "local"
608    }
609
610    /// Convert back to string representation
611    pub fn to_reference_string(&self) -> String {
612        let lookup_str = match &self.lookup {
613            LookupBy::Id(id) => format!("id.{}", id),
614            LookupBy::Name(name) => format!("name.{}", name),
615        };
616
617        match (&self.team, &self.resource_type) {
618            (Some(team), Some(rt)) => format!("{}.{}.{}.{}", self.provider, team, rt, lookup_str),
619            (Some(team), None) => format!("{}.{}.{}", self.provider, team, lookup_str),
620            (None, _) => {
621                // For local with name lookup, can use shorthand
622                if self.provider == "local" {
623                    if let LookupBy::Name(name) = &self.lookup {
624                        return name.clone();
625                    }
626                }
627                format!("{}.{}", self.provider, lookup_str)
628            }
629        }
630    }
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636
637    #[test]
638    fn test_store_config_request_with_id() {
639        let request = StoreConfigRequest {
640            config_type: "gateway".to_string(),
641            id: Some("01k8ek6h9aahhnrv3benret1nn".to_string()),
642            config: "[proxy]\nid = \"test\"\n".to_string(),
643        };
644
645        let json = serde_json::to_string(&request).unwrap();
646        assert!(json.contains("\"type\":\"gateway\""));
647        assert!(json.contains("\"id\":\"01k8ek6h9aahhnrv3benret1nn\""));
648        assert!(json.contains("\"config\":"));
649        assert!(json.contains("[proxy]"));
650
651        // Test deserialization
652        let deserialized: StoreConfigRequest = serde_json::from_str(&json).unwrap();
653        assert_eq!(deserialized.config_type, "gateway");
654        assert_eq!(
655            deserialized.id,
656            Some("01k8ek6h9aahhnrv3benret1nn".to_string())
657        );
658    }
659
660    #[test]
661    fn test_store_config_request_without_id() {
662        let request = StoreConfigRequest {
663            config_type: "pipeline".to_string(),
664            id: None,
665            config: "[pipeline]\nname = \"test\"\n".to_string(),
666        };
667
668        let json = serde_json::to_string(&request).unwrap();
669        assert!(json.contains("\"type\":\"pipeline\""));
670        assert!(json.contains("\"config\":"));
671        // Should not contain the id field when None
672        assert!(!json.contains("\"id\""));
673
674        // Test deserialization
675        let deserialized: StoreConfigRequest = serde_json::from_str(&json).unwrap();
676        assert_eq!(deserialized.config_type, "pipeline");
677        assert_eq!(deserialized.id, None);
678    }
679
680    #[test]
681    fn test_store_config_request_type_field_rename() {
682        // Test that the "type" field is correctly serialized despite the field being named config_type
683        let json = r#"{"type":"transform","config":"[transform]\nname = \"test\"\n"}"#;
684        let request: StoreConfigRequest = serde_json::from_str(json).unwrap();
685        assert_eq!(request.config_type, "transform");
686        assert_eq!(request.id, None);
687    }
688
689    #[test]
690    fn test_store_config_response() {
691        let json = r#"{
692            "success": true,
693            "message": "Gateway configuration updated successfully",
694            "data": {
695                "id": "01k9npa4tatmwddk66xxpcr2r0",
696                "type": "gateway",
697                "action": "updated"
698            }
699        }"#;
700
701        let response: StoreConfigResponse = serde_json::from_str(json).unwrap();
702        assert_eq!(response.success, true);
703        assert!(response.message.contains("updated successfully"));
704        assert_eq!(response.data.id, "01k9npa4tatmwddk66xxpcr2r0");
705        assert_eq!(response.data.model_type, "gateway");
706        assert_eq!(response.data.action, "updated");
707    }
708
709    // ========================================================================================
710    // RESOURCE REFERENCE TESTS
711    // ========================================================================================
712
713    #[test]
714    fn test_resource_reference_bare_name() {
715        let r = ResourceReference::parse("my_ingress").unwrap();
716        assert_eq!(r.provider, "local");
717        assert_eq!(r.team, None);
718        assert_eq!(r.resource_type, None);
719        assert_eq!(r.lookup, LookupBy::Name("my_ingress".to_string()));
720        assert!(r.is_local());
721    }
722
723    #[test]
724    fn test_resource_reference_local_name() {
725        let r = ResourceReference::parse("local.name.fhir_api").unwrap();
726        assert_eq!(r.provider, "local");
727        assert_eq!(r.team, None);
728        assert_eq!(r.resource_type, None);
729        assert_eq!(r.lookup, LookupBy::Name("fhir_api".to_string()));
730        assert!(r.is_local());
731    }
732
733    #[test]
734    fn test_resource_reference_provider_id() {
735        let r = ResourceReference::parse("runbeam.id.01JGXYZ123ABC").unwrap();
736        assert_eq!(r.provider, "runbeam");
737        assert_eq!(r.team, None);
738        assert_eq!(r.resource_type, None);
739        assert_eq!(r.lookup, LookupBy::Id("01JGXYZ123ABC".to_string()));
740        assert!(!r.is_local());
741    }
742
743    #[test]
744    fn test_resource_reference_team_id() {
745        let r = ResourceReference::parse("runbeam.acme.id.01JGXYZ123ABC").unwrap();
746        assert_eq!(r.provider, "runbeam");
747        assert_eq!(r.team, Some("acme".to_string()));
748        assert_eq!(r.resource_type, None);
749        assert_eq!(r.lookup, LookupBy::Id("01JGXYZ123ABC".to_string()));
750    }
751
752    #[test]
753    fn test_resource_reference_full_path_name() {
754        let r = ResourceReference::parse("runbeam.acme_health.ingress.name.patient_api").unwrap();
755        assert_eq!(r.provider, "runbeam");
756        assert_eq!(r.team, Some("acme_health".to_string()));
757        assert_eq!(r.resource_type, Some(ResourceType::Ingress));
758        assert_eq!(r.lookup, LookupBy::Name("patient_api".to_string()));
759    }
760
761    #[test]
762    fn test_resource_reference_full_path_id() {
763        let r = ResourceReference::parse("runbeam.partner_lab.egress.id.01JGXYZ").unwrap();
764        assert_eq!(r.provider, "runbeam");
765        assert_eq!(r.team, Some("partner_lab".to_string()));
766        assert_eq!(r.resource_type, Some(ResourceType::Egress));
767        assert_eq!(r.lookup, LookupBy::Id("01JGXYZ".to_string()));
768    }
769
770    #[test]
771    fn test_resource_reference_all_types() {
772        assert!(ResourceReference::parse("runbeam.t.ingress.name.x").unwrap().resource_type == Some(ResourceType::Ingress));
773        assert!(ResourceReference::parse("runbeam.t.egress.name.x").unwrap().resource_type == Some(ResourceType::Egress));
774        assert!(ResourceReference::parse("runbeam.t.pipeline.name.x").unwrap().resource_type == Some(ResourceType::Pipeline));
775        assert!(ResourceReference::parse("runbeam.t.endpoint.name.x").unwrap().resource_type == Some(ResourceType::Endpoint));
776        assert!(ResourceReference::parse("runbeam.t.backend.name.x").unwrap().resource_type == Some(ResourceType::Backend));
777        assert!(ResourceReference::parse("runbeam.t.mesh.name.x").unwrap().resource_type == Some(ResourceType::Mesh));
778    }
779
780    #[test]
781    fn test_resource_reference_invalid_format() {
782        assert!(ResourceReference::parse("runbeam.team.invalid.name.x").is_err());
783        assert!(ResourceReference::parse("a.b").is_err());
784        assert!(ResourceReference::parse("a.b.c.d.e.f").is_err());
785    }
786
787    #[test]
788    fn test_resource_reference_to_string() {
789        // Bare name shorthand
790        let r = ResourceReference::parse("my_ingress").unwrap();
791        assert_eq!(r.to_reference_string(), "my_ingress");
792
793        // Full path
794        let r = ResourceReference::parse("runbeam.acme.ingress.name.patient_api").unwrap();
795        assert_eq!(r.to_reference_string(), "runbeam.acme.ingress.name.patient_api");
796
797        // Provider ID
798        let r = ResourceReference::parse("runbeam.id.01JGXYZ").unwrap();
799        assert_eq!(r.to_reference_string(), "runbeam.id.01JGXYZ");
800    }
801
802    #[test]
803    fn test_provider_config_default() {
804        let config = ProviderConfig::default();
805        assert_eq!(config.api, None);
806        assert_eq!(config.enabled, true);
807        assert_eq!(config.poll_interval_secs, 30);
808    }
809
810    #[test]
811    fn test_provider_config_serde() {
812        let json = r#"{"api":"https://app.runbeam.io","enabled":true,"poll_interval_secs":60}"#;
813        let config: ProviderConfig = serde_json::from_str(json).unwrap();
814        assert_eq!(config.api, Some("https://app.runbeam.io".to_string()));
815        assert_eq!(config.enabled, true);
816        assert_eq!(config.poll_interval_secs, 60);
817    }
818}