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