Skip to main content

grapsus_config/
flatten.rs

1//! Flattened configuration for runtime consumption.
2//!
3//! This module provides [`FlattenedConfig`] which transforms the hierarchical
4//! namespace/service configuration into a flat structure suitable for runtime
5//! lookups with qualified IDs.
6//!
7//! # Why Flatten?
8//!
9//! The hierarchical configuration is great for authoring (domain-driven organization),
10//! but at runtime we need fast lookups by qualified ID. Flattening:
11//!
12//! 1. Pre-computes qualified IDs for all resources
13//! 2. Collects scope-specific limits for runtime isolation
14//! 3. Enables O(1) lookups via HashMap
15
16use std::collections::HashMap;
17use grapsus_common::ids::{QualifiedId, Scope};
18use grapsus_common::limits::Limits;
19
20use crate::{AgentConfig, Config, FilterConfig, ListenerConfig, RouteConfig, UpstreamConfig};
21
22// ============================================================================
23// Flattened Configuration
24// ============================================================================
25
26/// Flattened configuration with all resources indexed by qualified IDs.
27///
28/// This structure is produced by [`Config::flatten()`] and provides efficient
29/// runtime lookups for all resource types.
30#[derive(Debug, Clone)]
31pub struct FlattenedConfig {
32    /// All upstreams indexed by their qualified ID
33    pub upstreams: HashMap<QualifiedId, UpstreamConfig>,
34
35    /// All routes with their qualified IDs
36    pub routes: Vec<(QualifiedId, RouteConfig)>,
37
38    /// All agents indexed by their qualified ID
39    pub agents: HashMap<QualifiedId, AgentConfig>,
40
41    /// All filters indexed by their qualified ID
42    pub filters: HashMap<QualifiedId, FilterConfig>,
43
44    /// All listeners with their qualified IDs
45    pub listeners: Vec<(QualifiedId, ListenerConfig)>,
46
47    /// Limits per scope for runtime isolation
48    pub scope_limits: HashMap<Scope, Limits>,
49
50    /// Exported upstream names (for fast lookup)
51    pub exported_upstreams: HashMap<String, QualifiedId>,
52
53    /// Exported agent names (for fast lookup)
54    pub exported_agents: HashMap<String, QualifiedId>,
55
56    /// Exported filter names (for fast lookup)
57    pub exported_filters: HashMap<String, QualifiedId>,
58}
59
60impl FlattenedConfig {
61    /// Create a new empty flattened config.
62    pub fn new() -> Self {
63        Self {
64            upstreams: HashMap::new(),
65            routes: Vec::new(),
66            agents: HashMap::new(),
67            filters: HashMap::new(),
68            listeners: Vec::new(),
69            scope_limits: HashMap::new(),
70            exported_upstreams: HashMap::new(),
71            exported_agents: HashMap::new(),
72            exported_filters: HashMap::new(),
73        }
74    }
75
76    /// Get an upstream by its qualified ID.
77    pub fn get_upstream(&self, qid: &QualifiedId) -> Option<&UpstreamConfig> {
78        self.upstreams.get(qid)
79    }
80
81    /// Get an upstream by its canonical string form.
82    pub fn get_upstream_by_canonical(&self, canonical: &str) -> Option<&UpstreamConfig> {
83        self.upstreams.get(&QualifiedId::parse(canonical))
84    }
85
86    /// Get an agent by its qualified ID.
87    pub fn get_agent(&self, qid: &QualifiedId) -> Option<&AgentConfig> {
88        self.agents.get(qid)
89    }
90
91    /// Get a filter by its qualified ID.
92    pub fn get_filter(&self, qid: &QualifiedId) -> Option<&FilterConfig> {
93        self.filters.get(qid)
94    }
95
96    /// Get limits for a specific scope.
97    ///
98    /// Returns the limits for the most specific scope that has limits defined.
99    /// If no limits are defined for the scope, returns None.
100    pub fn get_limits(&self, scope: &Scope) -> Option<&Limits> {
101        self.scope_limits.get(scope)
102    }
103
104    /// Get effective limits for a scope, falling back through the scope chain.
105    ///
106    /// Searches from the given scope up through parent scopes until limits are found.
107    pub fn get_effective_limits(&self, scope: &Scope) -> Option<&Limits> {
108        for s in scope.chain() {
109            if let Some(limits) = self.scope_limits.get(&s) {
110                return Some(limits);
111            }
112        }
113        None
114    }
115
116    /// Get all routes in a specific scope.
117    pub fn routes_in_scope<'a>(
118        &'a self,
119        scope: &'a Scope,
120    ) -> impl Iterator<Item = &'a (QualifiedId, RouteConfig)> {
121        self.routes
122            .iter()
123            .filter(move |(qid, _)| &qid.scope == scope)
124    }
125
126    /// Get all listeners in a specific scope.
127    pub fn listeners_in_scope<'a>(
128        &'a self,
129        scope: &'a Scope,
130    ) -> impl Iterator<Item = &'a (QualifiedId, ListenerConfig)> {
131        self.listeners
132            .iter()
133            .filter(move |(qid, _)| &qid.scope == scope)
134    }
135
136    /// Check if an upstream name is exported.
137    pub fn is_upstream_exported(&self, name: &str) -> bool {
138        self.exported_upstreams.contains_key(name)
139    }
140
141    /// Get the qualified ID of an exported upstream by its local name.
142    pub fn get_exported_upstream_qid(&self, name: &str) -> Option<&QualifiedId> {
143        self.exported_upstreams.get(name)
144    }
145}
146
147impl Default for FlattenedConfig {
148    fn default() -> Self {
149        Self::new()
150    }
151}
152
153// ============================================================================
154// Config Flattening Implementation
155// ============================================================================
156
157impl Config {
158    /// Flatten the hierarchical configuration into a runtime-friendly structure.
159    ///
160    /// This converts all namespace/service resources into qualified IDs and
161    /// collects them into flat HashMaps for efficient lookup.
162    pub fn flatten(&self) -> FlattenedConfig {
163        let mut flat = FlattenedConfig::new();
164
165        // Add global limits
166        flat.scope_limits.insert(Scope::Global, self.limits.clone());
167
168        // Flatten global resources
169        self.flatten_global(&mut flat);
170
171        // Flatten namespaces
172        for ns in &self.namespaces {
173            self.flatten_namespace(ns, &mut flat);
174        }
175
176        flat
177    }
178
179    fn flatten_global(&self, flat: &mut FlattenedConfig) {
180        // Global upstreams
181        for (id, upstream) in &self.upstreams {
182            flat.upstreams
183                .insert(QualifiedId::global(id), upstream.clone());
184        }
185
186        // Global routes
187        for route in &self.routes {
188            flat.routes
189                .push((QualifiedId::global(&route.id), route.clone()));
190        }
191
192        // Global agents
193        for agent in &self.agents {
194            flat.agents
195                .insert(QualifiedId::global(&agent.id), agent.clone());
196        }
197
198        // Global filters
199        for (id, filter) in &self.filters {
200            flat.filters.insert(QualifiedId::global(id), filter.clone());
201        }
202
203        // Global listeners
204        for listener in &self.listeners {
205            flat.listeners
206                .push((QualifiedId::global(&listener.id), listener.clone()));
207        }
208    }
209
210    fn flatten_namespace(&self, ns: &crate::NamespaceConfig, flat: &mut FlattenedConfig) {
211        let ns_scope = Scope::Namespace(ns.id.clone());
212
213        // Namespace limits (if defined)
214        if let Some(ref limits) = ns.limits {
215            flat.scope_limits.insert(ns_scope.clone(), limits.clone());
216        }
217
218        // Namespace upstreams
219        for (id, upstream) in &ns.upstreams {
220            let qid = QualifiedId::namespaced(&ns.id, id);
221            flat.upstreams.insert(qid.clone(), upstream.clone());
222
223            // Track exports
224            if ns.exports.upstreams.contains(id) {
225                flat.exported_upstreams.insert(id.clone(), qid);
226            }
227        }
228
229        // Namespace routes
230        for route in &ns.routes {
231            flat.routes
232                .push((QualifiedId::namespaced(&ns.id, &route.id), route.clone()));
233        }
234
235        // Namespace agents
236        for agent in &ns.agents {
237            let qid = QualifiedId::namespaced(&ns.id, &agent.id);
238            flat.agents.insert(qid.clone(), agent.clone());
239
240            // Track exports
241            if ns.exports.agents.contains(&agent.id) {
242                flat.exported_agents.insert(agent.id.clone(), qid);
243            }
244        }
245
246        // Namespace filters
247        for (id, filter) in &ns.filters {
248            let qid = QualifiedId::namespaced(&ns.id, id);
249            flat.filters.insert(qid.clone(), filter.clone());
250
251            // Track exports
252            if ns.exports.filters.contains(id) {
253                flat.exported_filters.insert(id.clone(), qid);
254            }
255        }
256
257        // Namespace listeners
258        for listener in &ns.listeners {
259            flat.listeners.push((
260                QualifiedId::namespaced(&ns.id, &listener.id),
261                listener.clone(),
262            ));
263        }
264
265        // Flatten services within namespace
266        for svc in &ns.services {
267            self.flatten_service(&ns.id, svc, flat);
268        }
269    }
270
271    fn flatten_service(&self, ns_id: &str, svc: &crate::ServiceConfig, flat: &mut FlattenedConfig) {
272        let svc_scope = Scope::Service {
273            namespace: ns_id.to_string(),
274            service: svc.id.clone(),
275        };
276
277        // Service limits (if defined)
278        if let Some(ref limits) = svc.limits {
279            flat.scope_limits.insert(svc_scope.clone(), limits.clone());
280        }
281
282        // Service upstreams
283        for (id, upstream) in &svc.upstreams {
284            flat.upstreams.insert(
285                QualifiedId::in_service(ns_id, &svc.id, id),
286                upstream.clone(),
287            );
288        }
289
290        // Service routes
291        for route in &svc.routes {
292            flat.routes.push((
293                QualifiedId::in_service(ns_id, &svc.id, &route.id),
294                route.clone(),
295            ));
296        }
297
298        // Service agents
299        for agent in &svc.agents {
300            flat.agents.insert(
301                QualifiedId::in_service(ns_id, &svc.id, &agent.id),
302                agent.clone(),
303            );
304        }
305
306        // Service filters
307        for (id, filter) in &svc.filters {
308            flat.filters
309                .insert(QualifiedId::in_service(ns_id, &svc.id, id), filter.clone());
310        }
311
312        // Service listener (singular)
313        if let Some(ref listener) = svc.listener {
314            flat.listeners.push((
315                QualifiedId::in_service(ns_id, &svc.id, &listener.id),
316                listener.clone(),
317            ));
318        }
319    }
320}
321
322// ============================================================================
323// Tests
324// ============================================================================
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use crate::{
330        namespace::{ExportConfig, NamespaceConfig, ServiceConfig},
331        ConnectionPoolConfig, HttpVersionConfig, UpstreamTarget, UpstreamTimeouts,
332    };
333    use grapsus_common::types::LoadBalancingAlgorithm;
334
335    fn test_upstream(id: &str) -> UpstreamConfig {
336        UpstreamConfig {
337            id: id.to_string(),
338            targets: vec![UpstreamTarget {
339                address: "127.0.0.1:8080".to_string(),
340                weight: 1,
341                max_requests: None,
342                metadata: HashMap::new(),
343            }],
344            load_balancing: LoadBalancingAlgorithm::RoundRobin,
345            sticky_session: None,
346            health_check: None,
347            connection_pool: ConnectionPoolConfig::default(),
348            timeouts: UpstreamTimeouts::default(),
349            tls: None,
350            http_version: HttpVersionConfig::default(),
351        }
352    }
353
354    fn test_config() -> Config {
355        let mut config = Config::default_for_testing();
356
357        // Add global upstream
358        config.upstreams.insert(
359            "global-backend".to_string(),
360            test_upstream("global-backend"),
361        );
362
363        // Add namespace with upstream
364        let mut ns = NamespaceConfig::new("api");
365        ns.upstreams
366            .insert("ns-backend".to_string(), test_upstream("ns-backend"));
367        ns.upstreams.insert(
368            "shared-backend".to_string(),
369            test_upstream("shared-backend"),
370        );
371        ns.exports = ExportConfig {
372            upstreams: vec!["shared-backend".to_string()],
373            agents: vec![],
374            filters: vec![],
375        };
376
377        // Add service with upstream
378        let mut svc = ServiceConfig::new("payments");
379        svc.upstreams
380            .insert("svc-backend".to_string(), test_upstream("svc-backend"));
381        ns.services.push(svc);
382
383        config.namespaces.push(ns);
384        config
385    }
386
387    #[test]
388    fn test_flatten_global_upstreams() {
389        let config = test_config();
390        let flat = config.flatten();
391
392        // Should have global upstream
393        let qid = QualifiedId::global("global-backend");
394        assert!(flat.upstreams.contains_key(&qid));
395        assert_eq!(flat.get_upstream(&qid).unwrap().id, "global-backend");
396    }
397
398    #[test]
399    fn test_flatten_namespace_upstreams() {
400        let config = test_config();
401        let flat = config.flatten();
402
403        // Should have namespace upstream
404        let qid = QualifiedId::namespaced("api", "ns-backend");
405        assert!(flat.upstreams.contains_key(&qid));
406        assert_eq!(flat.get_upstream(&qid).unwrap().id, "ns-backend");
407    }
408
409    #[test]
410    fn test_flatten_service_upstreams() {
411        let config = test_config();
412        let flat = config.flatten();
413
414        // Should have service upstream
415        let qid = QualifiedId::in_service("api", "payments", "svc-backend");
416        assert!(flat.upstreams.contains_key(&qid));
417        assert_eq!(flat.get_upstream(&qid).unwrap().id, "svc-backend");
418    }
419
420    #[test]
421    fn test_flatten_exported_upstreams() {
422        let config = test_config();
423        let flat = config.flatten();
424
425        // Should track exported upstreams
426        assert!(flat.is_upstream_exported("shared-backend"));
427        assert!(!flat.is_upstream_exported("ns-backend"));
428
429        let exported_qid = flat.get_exported_upstream_qid("shared-backend").unwrap();
430        assert_eq!(exported_qid.canonical(), "api:shared-backend");
431    }
432
433    #[test]
434    fn test_get_upstream_by_canonical() {
435        let config = test_config();
436        let flat = config.flatten();
437
438        // Should lookup by canonical string
439        let upstream = flat.get_upstream_by_canonical("api:ns-backend").unwrap();
440        assert_eq!(upstream.id, "ns-backend");
441
442        let service_upstream = flat
443            .get_upstream_by_canonical("api:payments:svc-backend")
444            .unwrap();
445        assert_eq!(service_upstream.id, "svc-backend");
446    }
447
448    #[test]
449    fn test_flatten_scope_limits() {
450        let mut config = test_config();
451
452        // Add namespace limits
453        let ns = config.namespaces.get_mut(0).unwrap();
454        ns.limits = Some(Limits::for_testing());
455
456        let flat = config.flatten();
457
458        // Should have global limits
459        assert!(flat.scope_limits.contains_key(&Scope::Global));
460
461        // Should have namespace limits
462        assert!(flat
463            .scope_limits
464            .contains_key(&Scope::Namespace("api".to_string())));
465    }
466
467    #[test]
468    fn test_get_effective_limits() {
469        let mut config = test_config();
470
471        // Add namespace limits
472        let ns = config.namespaces.get_mut(0).unwrap();
473        ns.limits = Some(Limits::for_testing());
474
475        let flat = config.flatten();
476
477        // Service scope should fall back to namespace limits
478        let svc_scope = Scope::Service {
479            namespace: "api".to_string(),
480            service: "payments".to_string(),
481        };
482        let limits = flat.get_effective_limits(&svc_scope);
483        assert!(limits.is_some());
484    }
485
486    #[test]
487    fn test_routes_in_scope() {
488        let config = test_config();
489        let flat = config.flatten();
490
491        // Should have global routes (from default_for_testing)
492        let global_routes: Vec<_> = flat.routes_in_scope(&Scope::Global).collect();
493        assert!(!global_routes.is_empty());
494    }
495
496    #[test]
497    fn test_flatten_preserves_route_order() {
498        let config = test_config();
499        let flat = config.flatten();
500
501        // Routes should maintain order within their scope
502        let route_ids: Vec<_> = flat.routes.iter().map(|(qid, _)| qid.canonical()).collect();
503        assert!(!route_ids.is_empty());
504    }
505}