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    /// Validate if a link combination is allowed
116    ///
117    /// If no validation rules are defined, all combinations are allowed (permissive mode)
118    pub fn is_valid_link(&self, link_type: &str, source_type: &str, target_type: &str) -> bool {
119        // If no validation rules, accept everything
120        let Some(rules) = &self.validation_rules else {
121            return true;
122        };
123
124        // Check if there are rules for this link type
125        let Some(link_rules) = rules.get(link_type) else {
126            return true; // No rules for this link type, accept
127        };
128
129        // Check if the combination is in the rules
130        link_rules.iter().any(|rule| {
131            rule.source == source_type && rule.targets.contains(&target_type.to_string())
132        })
133    }
134
135    /// Find a link definition
136    pub fn find_link_definition(
137        &self,
138        link_type: &str,
139        source_type: &str,
140        target_type: &str,
141    ) -> Option<&LinkDefinition> {
142        self.links.iter().find(|def| {
143            def.link_type == link_type
144                && def.source_type == source_type
145                && def.target_type == target_type
146        })
147    }
148
149    /// Create a default configuration for testing
150    pub fn default_config() -> Self {
151        Self {
152            entities: vec![
153                EntityConfig {
154                    singular: "user".to_string(),
155                    plural: "users".to_string(),
156                    auth: EntityAuthConfig::default(),
157                },
158                EntityConfig {
159                    singular: "company".to_string(),
160                    plural: "companies".to_string(),
161                    auth: EntityAuthConfig::default(),
162                },
163                EntityConfig {
164                    singular: "car".to_string(),
165                    plural: "cars".to_string(),
166                    auth: EntityAuthConfig::default(),
167                },
168            ],
169            links: vec![
170                LinkDefinition {
171                    link_type: "owner".to_string(),
172                    source_type: "user".to_string(),
173                    target_type: "car".to_string(),
174                    forward_route_name: "cars-owned".to_string(),
175                    reverse_route_name: "users-owners".to_string(),
176                    description: Some("User owns a car".to_string()),
177                    required_fields: None,
178                    auth: None,
179                },
180                LinkDefinition {
181                    link_type: "driver".to_string(),
182                    source_type: "user".to_string(),
183                    target_type: "car".to_string(),
184                    forward_route_name: "cars-driven".to_string(),
185                    reverse_route_name: "users-drivers".to_string(),
186                    description: Some("User drives a car".to_string()),
187                    required_fields: None,
188                    auth: None,
189                },
190                LinkDefinition {
191                    link_type: "worker".to_string(),
192                    source_type: "user".to_string(),
193                    target_type: "company".to_string(),
194                    forward_route_name: "companies-work".to_string(),
195                    reverse_route_name: "users-workers".to_string(),
196                    description: Some("User works at a company".to_string()),
197                    required_fields: Some(vec!["role".to_string()]),
198                    auth: None,
199                },
200            ],
201            validation_rules: None,
202        }
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn test_default_config() {
212        let config = LinksConfig::default_config();
213
214        assert_eq!(config.entities.len(), 3);
215        assert_eq!(config.links.len(), 3);
216    }
217
218    #[test]
219    fn test_yaml_serialization() {
220        let config = LinksConfig::default_config();
221        let yaml = serde_yaml::to_string(&config).unwrap();
222
223        // Should be able to parse it back
224        let parsed = LinksConfig::from_yaml_str(&yaml).unwrap();
225        assert_eq!(parsed.entities.len(), config.entities.len());
226        assert_eq!(parsed.links.len(), config.links.len());
227    }
228
229    #[test]
230    fn test_link_auth_config_parsing() {
231        let yaml = r#"
232entities:
233  - singular: order
234    plural: orders
235  - singular: invoice
236    plural: invoices
237
238links:
239  - link_type: has_invoice
240    source_type: order
241    target_type: invoice
242    forward_route_name: invoices
243    reverse_route_name: order
244    auth:
245      list: authenticated
246      create: service_only
247      delete: admin_only
248"#;
249
250        let config = LinksConfig::from_yaml_str(yaml).unwrap();
251        assert_eq!(config.links.len(), 1);
252
253        let link_def = &config.links[0];
254        assert!(link_def.auth.is_some());
255
256        let auth = link_def.auth.as_ref().unwrap();
257        assert_eq!(auth.list, "authenticated");
258        assert_eq!(auth.create, "service_only");
259        assert_eq!(auth.delete, "admin_only");
260    }
261
262    #[test]
263    fn test_link_without_auth_config() {
264        let yaml = r#"
265entities:
266  - singular: invoice
267    plural: invoices
268  - singular: payment
269    plural: payments
270
271links:
272  - link_type: payment
273    source_type: invoice
274    target_type: payment
275    forward_route_name: payments
276    reverse_route_name: invoice
277"#;
278
279        let config = LinksConfig::from_yaml_str(yaml).unwrap();
280        assert_eq!(config.links.len(), 1);
281
282        let link_def = &config.links[0];
283        assert!(link_def.auth.is_none());
284    }
285
286    #[test]
287    fn test_mixed_link_auth_configs() {
288        let yaml = r#"
289entities:
290  - singular: order
291    plural: orders
292  - singular: invoice
293    plural: invoices
294  - singular: payment
295    plural: payments
296
297links:
298  - link_type: has_invoice
299    source_type: order
300    target_type: invoice
301    forward_route_name: invoices
302    reverse_route_name: order
303    auth:
304      list: authenticated
305      create: service_only
306      delete: admin_only
307  
308  - link_type: payment
309    source_type: invoice
310    target_type: payment
311    forward_route_name: payments
312    reverse_route_name: invoice
313"#;
314
315        let config = LinksConfig::from_yaml_str(yaml).unwrap();
316        assert_eq!(config.links.len(), 2);
317
318        // First link has auth
319        assert!(config.links[0].auth.is_some());
320        let auth1 = config.links[0].auth.as_ref().unwrap();
321        assert_eq!(auth1.create, "service_only");
322
323        // Second link has no auth
324        assert!(config.links[1].auth.is_none());
325    }
326}