Skip to main content

sentinel_common/
ids.rs

1//! Type-safe identifier newtypes for Sentinel proxy.
2//!
3//! These types provide compile-time safety for identifiers, preventing
4//! accidental mixing of different ID types (e.g., passing a RouteId
5//! where an UpstreamId is expected).
6//!
7//! # Scoped Identifiers
8//!
9//! Sentinel supports hierarchical configuration through namespaces and services.
10//! The [`Scope`] enum represents where a resource is defined, and [`QualifiedId`]
11//! combines a local name with its scope for unambiguous identification.
12//!
13//! ```
14//! use sentinel_common::ids::{Scope, QualifiedId};
15//!
16//! // Global resource
17//! let global = QualifiedId::global("shared-auth");
18//! assert_eq!(global.canonical(), "shared-auth");
19//!
20//! // Namespace-scoped resource
21//! let namespaced = QualifiedId::namespaced("api", "backend");
22//! assert_eq!(namespaced.canonical(), "api:backend");
23//!
24//! // Service-scoped resource
25//! let service = QualifiedId::in_service("api", "payments", "checkout");
26//! assert_eq!(service.canonical(), "api:payments:checkout");
27//! ```
28
29use serde::{Deserialize, Serialize};
30use std::fmt;
31#[cfg(feature = "runtime")]
32use uuid::Uuid;
33
34// ============================================================================
35// Scope and Qualified ID Types
36// ============================================================================
37
38/// Represents where a resource is defined in the configuration hierarchy.
39///
40/// Sentinel supports three levels of scoping:
41/// - **Global**: Resources defined at the root level, visible everywhere
42/// - **Namespace**: Resources scoped to a namespace, visible within that namespace
43/// - **Service**: Resources scoped to a service within a namespace
44///
45/// The resolution order follows "most specific wins": Service → Namespace → Global.
46#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
47#[serde(tag = "type", rename_all = "snake_case")]
48#[derive(Default)]
49pub enum Scope {
50    /// Global scope - visible everywhere in the configuration
51    #[default]
52    Global,
53    /// Namespace scope - visible within the namespace and its services
54    Namespace(String),
55    /// Service scope - local to a specific service within a namespace
56    Service { namespace: String, service: String },
57}
58
59impl Scope {
60    /// Returns true if this is the global scope
61    pub fn is_global(&self) -> bool {
62        matches!(self, Scope::Global)
63    }
64
65    /// Returns true if this is a namespace scope
66    pub fn is_namespace(&self) -> bool {
67        matches!(self, Scope::Namespace(_))
68    }
69
70    /// Returns true if this is a service scope
71    pub fn is_service(&self) -> bool {
72        matches!(self, Scope::Service { .. })
73    }
74
75    /// Returns the namespace name if this scope is within a namespace
76    pub fn namespace(&self) -> Option<&str> {
77        match self {
78            Scope::Global => None,
79            Scope::Namespace(ns) => Some(ns),
80            Scope::Service { namespace, .. } => Some(namespace),
81        }
82    }
83
84    /// Returns the service name if this is a service scope
85    pub fn service(&self) -> Option<&str> {
86        match self {
87            Scope::Service { service, .. } => Some(service),
88            _ => None,
89        }
90    }
91
92    /// Returns the parent scope (Service → Namespace → Global)
93    pub fn parent(&self) -> Option<Scope> {
94        match self {
95            Scope::Global => None,
96            Scope::Namespace(_) => Some(Scope::Global),
97            Scope::Service { namespace, .. } => Some(Scope::Namespace(namespace.clone())),
98        }
99    }
100
101    /// Returns the scope chain from most specific to least specific
102    pub fn chain(&self) -> Vec<Scope> {
103        let mut chain = vec![self.clone()];
104        let mut current = self.clone();
105        while let Some(parent) = current.parent() {
106            chain.push(parent.clone());
107            current = parent;
108        }
109        chain
110    }
111}
112
113impl fmt::Display for Scope {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        match self {
116            Scope::Global => write!(f, "global"),
117            Scope::Namespace(ns) => write!(f, "namespace:{}", ns),
118            Scope::Service { namespace, service } => {
119                write!(f, "service:{}:{}", namespace, service)
120            }
121        }
122    }
123}
124
125/// A qualified identifier combining a local name with its scope.
126///
127/// Qualified IDs enable unambiguous resource identification across
128/// the configuration hierarchy. They support both qualified references
129/// (e.g., `api:backend`) and unqualified references that resolve
130/// through the scope chain.
131///
132/// # Canonical Form
133///
134/// The canonical string representation uses `:` as a separator:
135/// - Global: `"name"`
136/// - Namespace: `"namespace:name"`
137/// - Service: `"namespace:service:name"`
138#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
139pub struct QualifiedId {
140    /// The local name within the scope
141    pub name: String,
142    /// The scope where this resource is defined
143    pub scope: Scope,
144}
145
146impl QualifiedId {
147    /// Create a new qualified ID with the given name and scope
148    pub fn new(name: impl Into<String>, scope: Scope) -> Self {
149        Self {
150            name: name.into(),
151            scope,
152        }
153    }
154
155    /// Create a global-scope qualified ID
156    pub fn global(name: impl Into<String>) -> Self {
157        Self {
158            name: name.into(),
159            scope: Scope::Global,
160        }
161    }
162
163    /// Create a namespace-scoped qualified ID
164    pub fn namespaced(namespace: impl Into<String>, name: impl Into<String>) -> Self {
165        Self {
166            name: name.into(),
167            scope: Scope::Namespace(namespace.into()),
168        }
169    }
170
171    /// Create a service-scoped qualified ID
172    pub fn in_service(
173        namespace: impl Into<String>,
174        service: impl Into<String>,
175        name: impl Into<String>,
176    ) -> Self {
177        Self {
178            name: name.into(),
179            scope: Scope::Service {
180                namespace: namespace.into(),
181                service: service.into(),
182            },
183        }
184    }
185
186    /// Returns the canonical string form of this qualified ID
187    ///
188    /// Format:
189    /// - Global: `"name"`
190    /// - Namespace: `"namespace:name"`
191    /// - Service: `"namespace:service:name"`
192    pub fn canonical(&self) -> String {
193        match &self.scope {
194            Scope::Global => self.name.clone(),
195            Scope::Namespace(ns) => format!("{}:{}", ns, self.name),
196            Scope::Service { namespace, service } => {
197                format!("{}:{}:{}", namespace, service, self.name)
198            }
199        }
200    }
201
202    /// Parse a qualified ID from its canonical string form
203    ///
204    /// Parsing rules:
205    /// - No colons: Global scope (`"name"` → Global)
206    /// - One colon: Namespace scope (`"ns:name"` → Namespace)
207    /// - Two+ colons: Service scope (`"ns:svc:name"` → Service)
208    pub fn parse(s: &str) -> Self {
209        let parts: Vec<&str> = s.splitn(3, ':').collect();
210        match parts.as_slice() {
211            [name] => Self::global(*name),
212            [namespace, name] => Self::namespaced(*namespace, *name),
213            [namespace, service, name] => Self::in_service(*namespace, *service, *name),
214            _ => Self::global(s), // Fallback for empty string
215        }
216    }
217
218    /// Returns true if this ID is in the global scope
219    pub fn is_global(&self) -> bool {
220        self.scope.is_global()
221    }
222
223    /// Returns true if this is a qualified (non-global) ID
224    pub fn is_qualified(&self) -> bool {
225        !self.scope.is_global()
226    }
227
228    /// Returns the local name
229    pub fn name(&self) -> &str {
230        &self.name
231    }
232
233    /// Returns the scope
234    pub fn scope(&self) -> &Scope {
235        &self.scope
236    }
237}
238
239impl fmt::Display for QualifiedId {
240    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
241        write!(f, "{}", self.canonical())
242    }
243}
244
245impl From<&str> for QualifiedId {
246    fn from(s: &str) -> Self {
247        Self::parse(s)
248    }
249}
250
251impl From<String> for QualifiedId {
252    fn from(s: String) -> Self {
253        Self::parse(&s)
254    }
255}
256
257// ============================================================================
258// Original ID Types
259// ============================================================================
260
261/// Unique correlation ID for request tracing across components.
262///
263/// Correlation IDs follow requests through the entire proxy pipeline,
264/// enabling end-to-end tracing and log correlation.
265#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
266pub struct CorrelationId(String);
267
268impl CorrelationId {
269    /// Create a new random correlation ID (requires runtime feature)
270    #[cfg(feature = "runtime")]
271    pub fn new() -> Self {
272        Self(Uuid::new_v4().to_string())
273    }
274
275    /// Create from an existing string
276    pub fn from_string(s: impl Into<String>) -> Self {
277        Self(s.into())
278    }
279
280    /// Get the inner string value
281    pub fn as_str(&self) -> &str {
282        &self.0
283    }
284
285    /// Convert to owned String
286    pub fn into_string(self) -> String {
287        self.0
288    }
289}
290
291#[cfg(feature = "runtime")]
292impl Default for CorrelationId {
293    fn default() -> Self {
294        Self::new()
295    }
296}
297
298impl fmt::Display for CorrelationId {
299    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
300        write!(f, "{}", self.0)
301    }
302}
303
304impl From<String> for CorrelationId {
305    fn from(s: String) -> Self {
306        Self(s)
307    }
308}
309
310impl From<&str> for CorrelationId {
311    fn from(s: &str) -> Self {
312        Self(s.to_string())
313    }
314}
315
316/// Unique request ID for internal tracking.
317///
318/// Request IDs are generated per-request and used for internal
319/// metrics, logging, and debugging.
320#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
321pub struct RequestId(String);
322
323impl RequestId {
324    /// Create a new random request ID (requires runtime feature)
325    #[cfg(feature = "runtime")]
326    pub fn new() -> Self {
327        Self(Uuid::new_v4().to_string())
328    }
329
330    /// Get the inner string value
331    pub fn as_str(&self) -> &str {
332        &self.0
333    }
334}
335
336#[cfg(feature = "runtime")]
337impl Default for RequestId {
338    fn default() -> Self {
339        Self::new()
340    }
341}
342
343impl fmt::Display for RequestId {
344    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
345        write!(f, "{}", self.0)
346    }
347}
348
349/// Route identifier.
350///
351/// Identifies a configured route in the proxy. Routes define
352/// how requests are matched and forwarded to upstreams.
353#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
354pub struct RouteId(String);
355
356impl RouteId {
357    pub fn new(id: impl Into<String>) -> Self {
358        Self(id.into())
359    }
360
361    pub fn as_str(&self) -> &str {
362        &self.0
363    }
364}
365
366impl fmt::Display for RouteId {
367    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
368        write!(f, "{}", self.0)
369    }
370}
371
372/// Upstream identifier.
373///
374/// Identifies a configured upstream pool. Upstreams are groups
375/// of backend servers that handle requests.
376#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
377pub struct UpstreamId(String);
378
379impl UpstreamId {
380    pub fn new(id: impl Into<String>) -> Self {
381        Self(id.into())
382    }
383
384    pub fn as_str(&self) -> &str {
385        &self.0
386    }
387}
388
389impl fmt::Display for UpstreamId {
390    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
391        write!(f, "{}", self.0)
392    }
393}
394
395/// Agent identifier.
396///
397/// Identifies a configured external processing agent (WAF, auth, etc.).
398#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
399pub struct AgentId(String);
400
401impl AgentId {
402    pub fn new(id: impl Into<String>) -> Self {
403        Self(id.into())
404    }
405
406    pub fn as_str(&self) -> &str {
407        &self.0
408    }
409}
410
411impl fmt::Display for AgentId {
412    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
413        write!(f, "{}", self.0)
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    // ========================================================================
422    // Scope Tests
423    // ========================================================================
424
425    #[test]
426    fn test_scope_global() {
427        let scope = Scope::Global;
428        assert!(scope.is_global());
429        assert!(!scope.is_namespace());
430        assert!(!scope.is_service());
431        assert_eq!(scope.namespace(), None);
432        assert_eq!(scope.service(), None);
433        assert_eq!(scope.parent(), None);
434    }
435
436    #[test]
437    fn test_scope_namespace() {
438        let scope = Scope::Namespace("api".to_string());
439        assert!(!scope.is_global());
440        assert!(scope.is_namespace());
441        assert!(!scope.is_service());
442        assert_eq!(scope.namespace(), Some("api"));
443        assert_eq!(scope.service(), None);
444        assert_eq!(scope.parent(), Some(Scope::Global));
445    }
446
447    #[test]
448    fn test_scope_service() {
449        let scope = Scope::Service {
450            namespace: "api".to_string(),
451            service: "payments".to_string(),
452        };
453        assert!(!scope.is_global());
454        assert!(!scope.is_namespace());
455        assert!(scope.is_service());
456        assert_eq!(scope.namespace(), Some("api"));
457        assert_eq!(scope.service(), Some("payments"));
458        assert_eq!(scope.parent(), Some(Scope::Namespace("api".to_string())));
459    }
460
461    #[test]
462    fn test_scope_chain() {
463        let service_scope = Scope::Service {
464            namespace: "api".to_string(),
465            service: "payments".to_string(),
466        };
467        let chain = service_scope.chain();
468        assert_eq!(chain.len(), 3);
469        assert_eq!(
470            chain[0],
471            Scope::Service {
472                namespace: "api".to_string(),
473                service: "payments".to_string()
474            }
475        );
476        assert_eq!(chain[1], Scope::Namespace("api".to_string()));
477        assert_eq!(chain[2], Scope::Global);
478    }
479
480    #[test]
481    fn test_scope_display() {
482        assert_eq!(Scope::Global.to_string(), "global");
483        assert_eq!(
484            Scope::Namespace("api".to_string()).to_string(),
485            "namespace:api"
486        );
487        assert_eq!(
488            Scope::Service {
489                namespace: "api".to_string(),
490                service: "payments".to_string()
491            }
492            .to_string(),
493            "service:api:payments"
494        );
495    }
496
497    // ========================================================================
498    // QualifiedId Tests
499    // ========================================================================
500
501    #[test]
502    fn test_qualified_id_global() {
503        let qid = QualifiedId::global("backend");
504        assert_eq!(qid.name(), "backend");
505        assert_eq!(qid.scope(), &Scope::Global);
506        assert_eq!(qid.canonical(), "backend");
507        assert!(qid.is_global());
508        assert!(!qid.is_qualified());
509    }
510
511    #[test]
512    fn test_qualified_id_namespaced() {
513        let qid = QualifiedId::namespaced("api", "backend");
514        assert_eq!(qid.name(), "backend");
515        assert_eq!(qid.scope(), &Scope::Namespace("api".to_string()));
516        assert_eq!(qid.canonical(), "api:backend");
517        assert!(!qid.is_global());
518        assert!(qid.is_qualified());
519    }
520
521    #[test]
522    fn test_qualified_id_service() {
523        let qid = QualifiedId::in_service("api", "payments", "checkout");
524        assert_eq!(qid.name(), "checkout");
525        assert_eq!(
526            qid.scope(),
527            &Scope::Service {
528                namespace: "api".to_string(),
529                service: "payments".to_string()
530            }
531        );
532        assert_eq!(qid.canonical(), "api:payments:checkout");
533        assert!(!qid.is_global());
534        assert!(qid.is_qualified());
535    }
536
537    #[test]
538    fn test_qualified_id_parse_global() {
539        let qid = QualifiedId::parse("backend");
540        assert_eq!(qid.name(), "backend");
541        assert_eq!(qid.scope(), &Scope::Global);
542    }
543
544    #[test]
545    fn test_qualified_id_parse_namespaced() {
546        let qid = QualifiedId::parse("api:backend");
547        assert_eq!(qid.name(), "backend");
548        assert_eq!(qid.scope(), &Scope::Namespace("api".to_string()));
549    }
550
551    #[test]
552    fn test_qualified_id_parse_service() {
553        let qid = QualifiedId::parse("api:payments:checkout");
554        assert_eq!(qid.name(), "checkout");
555        assert_eq!(
556            qid.scope(),
557            &Scope::Service {
558                namespace: "api".to_string(),
559                service: "payments".to_string()
560            }
561        );
562    }
563
564    #[test]
565    fn test_qualified_id_parse_with_extra_colons() {
566        // Names can contain colons after the service part
567        let qid = QualifiedId::parse("api:payments:item:with:colons");
568        assert_eq!(qid.name(), "item:with:colons");
569        assert_eq!(
570            qid.scope(),
571            &Scope::Service {
572                namespace: "api".to_string(),
573                service: "payments".to_string()
574            }
575        );
576    }
577
578    #[test]
579    fn test_qualified_id_from_str() {
580        let qid: QualifiedId = "api:backend".into();
581        assert_eq!(qid.canonical(), "api:backend");
582    }
583
584    #[test]
585    fn test_qualified_id_display() {
586        let qid = QualifiedId::in_service("ns", "svc", "resource");
587        assert_eq!(qid.to_string(), "ns:svc:resource");
588    }
589
590    #[test]
591    fn test_qualified_id_equality() {
592        let qid1 = QualifiedId::namespaced("api", "backend");
593        let qid2 = QualifiedId::parse("api:backend");
594        assert_eq!(qid1, qid2);
595    }
596
597    #[test]
598    fn test_qualified_id_hash() {
599        use std::collections::HashSet;
600
601        let mut set = HashSet::new();
602        set.insert(QualifiedId::global("backend"));
603        set.insert(QualifiedId::namespaced("api", "backend"));
604        set.insert(QualifiedId::in_service("api", "svc", "backend"));
605
606        // All three should be distinct
607        assert_eq!(set.len(), 3);
608
609        // Should find the namespaced one
610        assert!(set.contains(&QualifiedId::parse("api:backend")));
611    }
612
613    // ========================================================================
614    // Original ID Type Tests
615    // ========================================================================
616
617    #[test]
618    #[cfg(feature = "runtime")]
619    fn test_correlation_id() {
620        let id1 = CorrelationId::new();
621        let id2 = CorrelationId::from_string("test-id");
622
623        assert_ne!(id1, id2);
624        assert_eq!(id2.as_str(), "test-id");
625    }
626
627    #[test]
628    fn test_route_id() {
629        let id = RouteId::new("my-route");
630        assert_eq!(id.as_str(), "my-route");
631        assert_eq!(id.to_string(), "my-route");
632    }
633
634    #[test]
635    fn test_upstream_id() {
636        let id = UpstreamId::new("backend-pool");
637        assert_eq!(id.as_str(), "backend-pool");
638    }
639
640    #[test]
641    fn test_agent_id() {
642        let id = AgentId::new("waf-agent");
643        assert_eq!(id.as_str(), "waf-agent");
644    }
645}