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