this/config/
mod.rs

1//! Configuration loading and management
2
3use crate::core::LinkDefinition;
4use anyhow::Result;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8/// Authorization configuration for an entity
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct EntityAuthConfig {
11    /// Policy for listing entities (GET /{entities})
12    #[serde(default = "default_auth_policy")]
13    pub list: String,
14
15    /// Policy for getting a single entity (GET /{entities}/{id})
16    #[serde(default = "default_auth_policy")]
17    pub get: String,
18
19    /// Policy for creating an entity (POST /{entities})
20    #[serde(default = "default_auth_policy")]
21    pub create: String,
22
23    /// Policy for updating an entity (PUT /{entities}/{id})
24    #[serde(default = "default_auth_policy")]
25    pub update: String,
26
27    /// Policy for deleting an entity (DELETE /{entities}/{id})
28    #[serde(default = "default_auth_policy")]
29    pub delete: String,
30
31    /// Policy for listing links (GET /{entities}/{id}/{link_route})
32    #[serde(default = "default_auth_policy")]
33    pub list_links: String,
34
35    /// Policy for creating links (POST /{entities}/{id}/{link_type}/{target_type}/{target_id})
36    #[serde(default = "default_auth_policy")]
37    pub create_link: String,
38
39    /// Policy for deleting links (DELETE /{entities}/{id}/{link_type}/{target_type}/{target_id})
40    #[serde(default = "default_auth_policy")]
41    pub delete_link: String,
42}
43
44fn default_auth_policy() -> String {
45    "authenticated".to_string()
46}
47
48impl Default for EntityAuthConfig {
49    fn default() -> Self {
50        Self {
51            list: default_auth_policy(),
52            get: default_auth_policy(),
53            create: default_auth_policy(),
54            update: default_auth_policy(),
55            delete: default_auth_policy(),
56            list_links: default_auth_policy(),
57            create_link: default_auth_policy(),
58            delete_link: default_auth_policy(),
59        }
60    }
61}
62
63/// Configuration for an entity type
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct EntityConfig {
66    /// Singular form (e.g., "user", "company")
67    pub singular: String,
68
69    /// Plural form (e.g., "users", "companies")
70    pub plural: String,
71
72    /// Authorization configuration
73    #[serde(default)]
74    pub auth: EntityAuthConfig,
75}
76
77/// Validation rule for a link type
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct ValidationRule {
80    /// Source entity type
81    pub source: String,
82
83    /// Allowed target entity types
84    pub targets: Vec<String>,
85}
86
87/// Complete configuration for the links system
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct LinksConfig {
90    /// List of entity configurations
91    pub entities: Vec<EntityConfig>,
92
93    /// List of link definitions
94    pub links: Vec<LinkDefinition>,
95
96    /// Optional validation rules (link_type -> rules)
97    #[serde(default)]
98    pub validation_rules: Option<HashMap<String, Vec<ValidationRule>>>,
99}
100
101impl LinksConfig {
102    /// Load configuration from a YAML file
103    pub fn from_yaml_file(path: &str) -> Result<Self> {
104        let content = std::fs::read_to_string(path)?;
105        let config: Self = serde_yaml::from_str(&content)?;
106        Ok(config)
107    }
108
109    /// Load configuration from a YAML string
110    pub fn from_yaml_str(yaml: &str) -> Result<Self> {
111        let config: Self = serde_yaml::from_str(yaml)?;
112        Ok(config)
113    }
114
115    /// Merge multiple configurations into one
116    ///
117    /// Rules:
118    /// - Entities: Combined from all configs, duplicates (by singular name) use last definition
119    /// - Links: Combined from all configs, duplicates (by link_type+source+target) use last definition
120    /// - Validation rules: Merged by link_type, rules combined for each link type
121    pub fn merge(configs: Vec<LinksConfig>) -> Self {
122        if configs.is_empty() {
123            return Self {
124                entities: vec![],
125                links: vec![],
126                validation_rules: None,
127            };
128        }
129
130        if configs.len() == 1 {
131            return configs.into_iter().next().unwrap();
132        }
133
134        let mut entities_map: HashMap<String, EntityConfig> = HashMap::new();
135        let mut links_map: HashMap<(String, String, String), LinkDefinition> = HashMap::new();
136        let mut validation_rules_map: HashMap<String, Vec<ValidationRule>> = HashMap::new();
137
138        // Merge entities (last one wins for duplicates)
139        for config in &configs {
140            for entity in &config.entities {
141                entities_map.insert(entity.singular.clone(), entity.clone());
142            }
143        }
144
145        // Merge links (last one wins for duplicates)
146        for config in &configs {
147            for link in &config.links {
148                let key = (
149                    link.link_type.clone(),
150                    link.source_type.clone(),
151                    link.target_type.clone(),
152                );
153                links_map.insert(key, link.clone());
154            }
155        }
156
157        // Merge validation rules (combine rules for same link_type)
158        for config in &configs {
159            if let Some(rules) = &config.validation_rules {
160                for (link_type, link_rules) in rules {
161                    validation_rules_map
162                        .entry(link_type.clone())
163                        .or_default()
164                        .extend(link_rules.clone());
165                }
166            }
167        }
168
169        // Convert back to vectors
170        let entities: Vec<EntityConfig> = entities_map.into_values().collect();
171        let links: Vec<LinkDefinition> = links_map.into_values().collect();
172        let validation_rules = if validation_rules_map.is_empty() {
173            None
174        } else {
175            Some(validation_rules_map)
176        };
177
178        Self {
179            entities,
180            links,
181            validation_rules,
182        }
183    }
184
185    /// Validate if a link combination is allowed
186    ///
187    /// If no validation rules are defined, all combinations are allowed (permissive mode)
188    pub fn is_valid_link(&self, link_type: &str, source_type: &str, target_type: &str) -> bool {
189        // If no validation rules, accept everything
190        let Some(rules) = &self.validation_rules else {
191            return true;
192        };
193
194        // Check if there are rules for this link type
195        let Some(link_rules) = rules.get(link_type) else {
196            return true; // No rules for this link type, accept
197        };
198
199        // Check if the combination is in the rules
200        link_rules.iter().any(|rule| {
201            rule.source == source_type && rule.targets.contains(&target_type.to_string())
202        })
203    }
204
205    /// Find a link definition
206    pub fn find_link_definition(
207        &self,
208        link_type: &str,
209        source_type: &str,
210        target_type: &str,
211    ) -> Option<&LinkDefinition> {
212        self.links.iter().find(|def| {
213            def.link_type == link_type
214                && def.source_type == source_type
215                && def.target_type == target_type
216        })
217    }
218
219    /// Create a default configuration for testing
220    pub fn default_config() -> Self {
221        Self {
222            entities: vec![
223                EntityConfig {
224                    singular: "user".to_string(),
225                    plural: "users".to_string(),
226                    auth: EntityAuthConfig::default(),
227                },
228                EntityConfig {
229                    singular: "company".to_string(),
230                    plural: "companies".to_string(),
231                    auth: EntityAuthConfig::default(),
232                },
233                EntityConfig {
234                    singular: "car".to_string(),
235                    plural: "cars".to_string(),
236                    auth: EntityAuthConfig::default(),
237                },
238            ],
239            links: vec![
240                LinkDefinition {
241                    link_type: "owner".to_string(),
242                    source_type: "user".to_string(),
243                    target_type: "car".to_string(),
244                    forward_route_name: "cars-owned".to_string(),
245                    reverse_route_name: "users-owners".to_string(),
246                    description: Some("User owns a car".to_string()),
247                    required_fields: None,
248                    auth: None,
249                },
250                LinkDefinition {
251                    link_type: "driver".to_string(),
252                    source_type: "user".to_string(),
253                    target_type: "car".to_string(),
254                    forward_route_name: "cars-driven".to_string(),
255                    reverse_route_name: "users-drivers".to_string(),
256                    description: Some("User drives a car".to_string()),
257                    required_fields: None,
258                    auth: None,
259                },
260                LinkDefinition {
261                    link_type: "worker".to_string(),
262                    source_type: "user".to_string(),
263                    target_type: "company".to_string(),
264                    forward_route_name: "companies-work".to_string(),
265                    reverse_route_name: "users-workers".to_string(),
266                    description: Some("User works at a company".to_string()),
267                    required_fields: Some(vec!["role".to_string()]),
268                    auth: None,
269                },
270            ],
271            validation_rules: None,
272        }
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn test_default_config() {
282        let config = LinksConfig::default_config();
283
284        assert_eq!(config.entities.len(), 3);
285        assert_eq!(config.links.len(), 3);
286    }
287
288    #[test]
289    fn test_yaml_serialization() {
290        let config = LinksConfig::default_config();
291        let yaml = serde_yaml::to_string(&config).unwrap();
292
293        // Should be able to parse it back
294        let parsed = LinksConfig::from_yaml_str(&yaml).unwrap();
295        assert_eq!(parsed.entities.len(), config.entities.len());
296        assert_eq!(parsed.links.len(), config.links.len());
297    }
298
299    #[test]
300    fn test_link_auth_config_parsing() {
301        let yaml = r#"
302entities:
303  - singular: order
304    plural: orders
305  - singular: invoice
306    plural: invoices
307
308links:
309  - link_type: has_invoice
310    source_type: order
311    target_type: invoice
312    forward_route_name: invoices
313    reverse_route_name: order
314    auth:
315      list: authenticated
316      create: service_only
317      delete: admin_only
318"#;
319
320        let config = LinksConfig::from_yaml_str(yaml).unwrap();
321        assert_eq!(config.links.len(), 1);
322
323        let link_def = &config.links[0];
324        assert!(link_def.auth.is_some());
325
326        let auth = link_def.auth.as_ref().unwrap();
327        assert_eq!(auth.list, "authenticated");
328        assert_eq!(auth.create, "service_only");
329        assert_eq!(auth.delete, "admin_only");
330    }
331
332    #[test]
333    fn test_link_without_auth_config() {
334        let yaml = r#"
335entities:
336  - singular: invoice
337    plural: invoices
338  - singular: payment
339    plural: payments
340
341links:
342  - link_type: payment
343    source_type: invoice
344    target_type: payment
345    forward_route_name: payments
346    reverse_route_name: invoice
347"#;
348
349        let config = LinksConfig::from_yaml_str(yaml).unwrap();
350        assert_eq!(config.links.len(), 1);
351
352        let link_def = &config.links[0];
353        assert!(link_def.auth.is_none());
354    }
355
356    #[test]
357    fn test_mixed_link_auth_configs() {
358        let yaml = r#"
359entities:
360  - singular: order
361    plural: orders
362  - singular: invoice
363    plural: invoices
364  - singular: payment
365    plural: payments
366
367links:
368  - link_type: has_invoice
369    source_type: order
370    target_type: invoice
371    forward_route_name: invoices
372    reverse_route_name: order
373    auth:
374      list: authenticated
375      create: service_only
376      delete: admin_only
377  
378  - link_type: payment
379    source_type: invoice
380    target_type: payment
381    forward_route_name: payments
382    reverse_route_name: invoice
383"#;
384
385        let config = LinksConfig::from_yaml_str(yaml).unwrap();
386        assert_eq!(config.links.len(), 2);
387
388        // First link has auth
389        assert!(config.links[0].auth.is_some());
390        let auth1 = config.links[0].auth.as_ref().unwrap();
391        assert_eq!(auth1.create, "service_only");
392
393        // Second link has no auth
394        assert!(config.links[1].auth.is_none());
395    }
396
397    #[test]
398    fn test_merge_empty() {
399        let merged = LinksConfig::merge(vec![]);
400        assert_eq!(merged.entities.len(), 0);
401        assert_eq!(merged.links.len(), 0);
402    }
403
404    #[test]
405    fn test_merge_single() {
406        let config = LinksConfig::default_config();
407        let merged = LinksConfig::merge(vec![config.clone()]);
408        assert_eq!(merged.entities.len(), config.entities.len());
409        assert_eq!(merged.links.len(), config.links.len());
410    }
411
412    #[test]
413    fn test_merge_disjoint_configs() {
414        let config1 = LinksConfig {
415            entities: vec![EntityConfig {
416                singular: "order".to_string(),
417                plural: "orders".to_string(),
418                auth: EntityAuthConfig::default(),
419            }],
420            links: vec![],
421            validation_rules: None,
422        };
423
424        let config2 = LinksConfig {
425            entities: vec![EntityConfig {
426                singular: "invoice".to_string(),
427                plural: "invoices".to_string(),
428                auth: EntityAuthConfig::default(),
429            }],
430            links: vec![],
431            validation_rules: None,
432        };
433
434        let merged = LinksConfig::merge(vec![config1, config2]);
435        assert_eq!(merged.entities.len(), 2);
436    }
437
438    #[test]
439    fn test_merge_overlapping_entities() {
440        let auth1 = EntityAuthConfig {
441            list: "public".to_string(),
442            ..Default::default()
443        };
444
445        let config1 = LinksConfig {
446            entities: vec![EntityConfig {
447                singular: "user".to_string(),
448                plural: "users".to_string(),
449                auth: auth1,
450            }],
451            links: vec![],
452            validation_rules: None,
453        };
454
455        let auth2 = EntityAuthConfig {
456            list: "authenticated".to_string(),
457            ..Default::default()
458        };
459
460        let config2 = LinksConfig {
461            entities: vec![EntityConfig {
462                singular: "user".to_string(),
463                plural: "users".to_string(),
464                auth: auth2,
465            }],
466            links: vec![],
467            validation_rules: None,
468        };
469
470        let merged = LinksConfig::merge(vec![config1, config2]);
471
472        // Should have only 1 entity (last wins)
473        assert_eq!(merged.entities.len(), 1);
474        assert_eq!(merged.entities[0].auth.list, "authenticated");
475    }
476
477    #[test]
478    fn test_merge_validation_rules() {
479        let mut rules1 = HashMap::new();
480        rules1.insert(
481            "works_at".to_string(),
482            vec![ValidationRule {
483                source: "user".to_string(),
484                targets: vec!["company".to_string()],
485            }],
486        );
487
488        let config1 = LinksConfig {
489            entities: vec![],
490            links: vec![],
491            validation_rules: Some(rules1),
492        };
493
494        let mut rules2 = HashMap::new();
495        rules2.insert(
496            "works_at".to_string(),
497            vec![ValidationRule {
498                source: "user".to_string(),
499                targets: vec!["project".to_string()],
500            }],
501        );
502
503        let config2 = LinksConfig {
504            entities: vec![],
505            links: vec![],
506            validation_rules: Some(rules2),
507        };
508
509        let merged = LinksConfig::merge(vec![config1, config2]);
510
511        // Validation rules should be combined
512        assert!(merged.validation_rules.is_some());
513        let rules = merged.validation_rules.unwrap();
514        assert_eq!(rules["works_at"].len(), 2);
515    }
516}