1use crate::core::LinkDefinition;
4use anyhow::Result;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct EntityAuthConfig {
11 #[serde(default = "default_auth_policy")]
13 pub list: String,
14
15 #[serde(default = "default_auth_policy")]
17 pub get: String,
18
19 #[serde(default = "default_auth_policy")]
21 pub create: String,
22
23 #[serde(default = "default_auth_policy")]
25 pub update: String,
26
27 #[serde(default = "default_auth_policy")]
29 pub delete: String,
30
31 #[serde(default = "default_auth_policy")]
33 pub list_links: String,
34
35 #[serde(default = "default_auth_policy")]
37 pub create_link: String,
38
39 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct EntityConfig {
66 pub singular: String,
68
69 pub plural: String,
71
72 #[serde(default)]
74 pub auth: EntityAuthConfig,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct ValidationRule {
80 pub source: String,
82
83 pub targets: Vec<String>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct LinksConfig {
90 pub entities: Vec<EntityConfig>,
92
93 pub links: Vec<LinkDefinition>,
95
96 #[serde(default)]
98 pub validation_rules: Option<HashMap<String, Vec<ValidationRule>>>,
99}
100
101impl LinksConfig {
102 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 pub fn from_yaml_str(yaml: &str) -> Result<Self> {
111 let config: Self = serde_yaml::from_str(yaml)?;
112 Ok(config)
113 }
114
115 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 for config in &configs {
140 for entity in &config.entities {
141 entities_map.insert(entity.singular.clone(), entity.clone());
142 }
143 }
144
145 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 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 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 pub fn is_valid_link(&self, link_type: &str, source_type: &str, target_type: &str) -> bool {
189 let Some(rules) = &self.validation_rules else {
191 return true;
192 };
193
194 let Some(link_rules) = rules.get(link_type) else {
196 return true; };
198
199 link_rules.iter().any(|rule| {
201 rule.source == source_type && rule.targets.contains(&target_type.to_string())
202 })
203 }
204
205 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 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 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 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 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 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 assert!(merged.validation_rules.is_some());
513 let rules = merged.validation_rules.unwrap();
514 assert_eq!(rules["works_at"].len(), 2);
515 }
516}