1pub mod events;
4pub mod sinks;
5
6use crate::core::LinkDefinition;
7use anyhow::Result;
8use indexmap::IndexMap;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12pub use events::*;
13pub use sinks::*;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct EntityAuthConfig {
18 #[serde(default = "default_auth_policy")]
20 pub list: String,
21
22 #[serde(default = "default_auth_policy")]
24 pub get: String,
25
26 #[serde(default = "default_auth_policy")]
28 pub create: String,
29
30 #[serde(default = "default_auth_policy")]
32 pub update: String,
33
34 #[serde(default = "default_auth_policy")]
36 pub delete: String,
37
38 #[serde(default = "default_auth_policy")]
40 pub list_links: String,
41
42 #[serde(default = "default_auth_policy")]
44 pub create_link: String,
45
46 #[serde(default = "default_auth_policy")]
48 pub delete_link: String,
49}
50
51fn default_auth_policy() -> String {
52 "authenticated".to_string()
53}
54
55impl Default for EntityAuthConfig {
56 fn default() -> Self {
57 Self {
58 list: default_auth_policy(),
59 get: default_auth_policy(),
60 create: default_auth_policy(),
61 update: default_auth_policy(),
62 delete: default_auth_policy(),
63 list_links: default_auth_policy(),
64 create_link: default_auth_policy(),
65 delete_link: default_auth_policy(),
66 }
67 }
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct EntityConfig {
73 pub singular: String,
75
76 pub plural: String,
78
79 #[serde(default)]
81 pub auth: EntityAuthConfig,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct ValidationRule {
87 pub source: String,
89
90 pub targets: Vec<String>,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct LinksConfig {
97 pub entities: Vec<EntityConfig>,
99
100 pub links: Vec<LinkDefinition>,
102
103 #[serde(default)]
105 pub validation_rules: Option<HashMap<String, Vec<ValidationRule>>>,
106
107 #[serde(default)]
109 pub events: Option<EventsConfig>,
110
111 #[serde(default)]
113 pub sinks: Option<Vec<SinkConfig>>,
114}
115
116impl LinksConfig {
117 pub fn from_yaml_file(path: &str) -> Result<Self> {
119 let content = std::fs::read_to_string(path)?;
120 let config: Self = serde_yaml::from_str(&content)?;
121 Ok(config)
122 }
123
124 pub fn from_yaml_str(yaml: &str) -> Result<Self> {
126 let config: Self = serde_yaml::from_str(yaml)?;
127 Ok(config)
128 }
129
130 pub fn merge(configs: Vec<LinksConfig>) -> Self {
137 if configs.is_empty() {
138 return Self {
139 entities: vec![],
140 links: vec![],
141 validation_rules: None,
142 events: None,
143 sinks: None,
144 };
145 }
146
147 if configs.len() == 1 {
148 return configs.into_iter().next().unwrap();
149 }
150
151 let mut entities_map: IndexMap<String, EntityConfig> = IndexMap::new();
152 let mut links_map: IndexMap<(String, String, String), LinkDefinition> = IndexMap::new();
153 let mut validation_rules_map: HashMap<String, Vec<ValidationRule>> = HashMap::new();
154
155 for config in &configs {
157 for entity in &config.entities {
158 entities_map.insert(entity.singular.clone(), entity.clone());
159 }
160 }
161
162 for config in &configs {
164 for link in &config.links {
165 let key = (
166 link.link_type.clone(),
167 link.source_type.clone(),
168 link.target_type.clone(),
169 );
170 links_map.insert(key, link.clone());
171 }
172 }
173
174 for config in &configs {
176 if let Some(rules) = &config.validation_rules {
177 for (link_type, link_rules) in rules {
178 validation_rules_map
179 .entry(link_type.clone())
180 .or_default()
181 .extend(link_rules.clone());
182 }
183 }
184 }
185
186 let mut merged_events: Option<EventsConfig> = None;
188 for config in &configs {
189 if let Some(events) = &config.events {
190 if let Some(ref mut existing) = merged_events {
191 existing.backend = events.backend.clone();
193 existing.flows.extend(events.flows.clone());
194 existing.consumers.extend(events.consumers.clone());
195 } else {
196 merged_events = Some(events.clone());
197 }
198 }
199 }
200
201 if let Some(ref events) = merged_events {
203 let mut seen_names = std::collections::HashSet::new();
204 for flow in &events.flows {
205 if !seen_names.insert(&flow.name) {
206 tracing::warn!(
207 flow_name = %flow.name,
208 "config merge: duplicate flow name detected — \
209 later definition will shadow earlier one at runtime"
210 );
211 }
212 }
213 }
214
215 let mut sinks_map: IndexMap<String, SinkConfig> = IndexMap::new();
217 for config in &configs {
218 if let Some(sinks) = &config.sinks {
219 for sink in sinks {
220 sinks_map.insert(sink.name.clone(), sink.clone());
221 }
222 }
223 }
224 let merged_sinks = if sinks_map.is_empty() {
225 None
226 } else {
227 Some(sinks_map.into_values().collect())
228 };
229
230 let entities: Vec<EntityConfig> = entities_map.into_values().collect();
232 let links: Vec<LinkDefinition> = links_map.into_values().collect();
233 let validation_rules = if validation_rules_map.is_empty() {
234 None
235 } else {
236 Some(validation_rules_map)
237 };
238
239 Self {
240 entities,
241 links,
242 validation_rules,
243 events: merged_events,
244 sinks: merged_sinks,
245 }
246 }
247
248 pub fn is_valid_link(&self, link_type: &str, source_type: &str, target_type: &str) -> bool {
252 let Some(rules) = &self.validation_rules else {
254 return true;
255 };
256
257 let Some(link_rules) = rules.get(link_type) else {
259 return true; };
261
262 link_rules.iter().any(|rule| {
264 rule.source == source_type && rule.targets.contains(&target_type.to_string())
265 })
266 }
267
268 pub fn find_link_definition(
270 &self,
271 link_type: &str,
272 source_type: &str,
273 target_type: &str,
274 ) -> Option<&LinkDefinition> {
275 self.links.iter().find(|def| {
276 def.link_type == link_type
277 && def.source_type == source_type
278 && def.target_type == target_type
279 })
280 }
281
282 pub fn default_config() -> Self {
284 Self {
285 entities: vec![
286 EntityConfig {
287 singular: "user".to_string(),
288 plural: "users".to_string(),
289 auth: EntityAuthConfig::default(),
290 },
291 EntityConfig {
292 singular: "company".to_string(),
293 plural: "companies".to_string(),
294 auth: EntityAuthConfig::default(),
295 },
296 EntityConfig {
297 singular: "car".to_string(),
298 plural: "cars".to_string(),
299 auth: EntityAuthConfig::default(),
300 },
301 ],
302 links: vec![
303 LinkDefinition {
304 link_type: "owner".to_string(),
305 source_type: "user".to_string(),
306 target_type: "car".to_string(),
307 forward_route_name: "cars-owned".to_string(),
308 reverse_route_name: "users-owners".to_string(),
309 description: Some("User owns a car".to_string()),
310 required_fields: None,
311 auth: None,
312 },
313 LinkDefinition {
314 link_type: "driver".to_string(),
315 source_type: "user".to_string(),
316 target_type: "car".to_string(),
317 forward_route_name: "cars-driven".to_string(),
318 reverse_route_name: "users-drivers".to_string(),
319 description: Some("User drives a car".to_string()),
320 required_fields: None,
321 auth: None,
322 },
323 LinkDefinition {
324 link_type: "worker".to_string(),
325 source_type: "user".to_string(),
326 target_type: "company".to_string(),
327 forward_route_name: "companies-work".to_string(),
328 reverse_route_name: "users-workers".to_string(),
329 description: Some("User works at a company".to_string()),
330 required_fields: Some(vec!["role".to_string()]),
331 auth: None,
332 },
333 ],
334 validation_rules: None,
335 events: None,
336 sinks: None,
337 }
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 #[test]
346 fn test_default_config() {
347 let config = LinksConfig::default_config();
348
349 assert_eq!(config.entities.len(), 3);
350 assert_eq!(config.links.len(), 3);
351 }
352
353 #[test]
354 fn test_yaml_serialization() {
355 let config = LinksConfig::default_config();
356 let yaml = serde_yaml::to_string(&config).unwrap();
357
358 let parsed = LinksConfig::from_yaml_str(&yaml).unwrap();
360 assert_eq!(parsed.entities.len(), config.entities.len());
361 assert_eq!(parsed.links.len(), config.links.len());
362 }
363
364 #[test]
365 fn test_link_auth_config_parsing() {
366 let yaml = r#"
367entities:
368 - singular: order
369 plural: orders
370 - singular: invoice
371 plural: invoices
372
373links:
374 - link_type: has_invoice
375 source_type: order
376 target_type: invoice
377 forward_route_name: invoices
378 reverse_route_name: order
379 auth:
380 list: authenticated
381 create: service_only
382 delete: admin_only
383"#;
384
385 let config = LinksConfig::from_yaml_str(yaml).unwrap();
386 assert_eq!(config.links.len(), 1);
387
388 let link_def = &config.links[0];
389 assert!(link_def.auth.is_some());
390
391 let auth = link_def.auth.as_ref().unwrap();
392 assert_eq!(auth.list, "authenticated");
393 assert_eq!(auth.create, "service_only");
394 assert_eq!(auth.delete, "admin_only");
395 }
396
397 #[test]
398 fn test_link_without_auth_config() {
399 let yaml = r#"
400entities:
401 - singular: invoice
402 plural: invoices
403 - singular: payment
404 plural: payments
405
406links:
407 - link_type: payment
408 source_type: invoice
409 target_type: payment
410 forward_route_name: payments
411 reverse_route_name: invoice
412"#;
413
414 let config = LinksConfig::from_yaml_str(yaml).unwrap();
415 assert_eq!(config.links.len(), 1);
416
417 let link_def = &config.links[0];
418 assert!(link_def.auth.is_none());
419 }
420
421 #[test]
422 fn test_mixed_link_auth_configs() {
423 let yaml = r#"
424entities:
425 - singular: order
426 plural: orders
427 - singular: invoice
428 plural: invoices
429 - singular: payment
430 plural: payments
431
432links:
433 - link_type: has_invoice
434 source_type: order
435 target_type: invoice
436 forward_route_name: invoices
437 reverse_route_name: order
438 auth:
439 list: authenticated
440 create: service_only
441 delete: admin_only
442
443 - link_type: payment
444 source_type: invoice
445 target_type: payment
446 forward_route_name: payments
447 reverse_route_name: invoice
448"#;
449
450 let config = LinksConfig::from_yaml_str(yaml).unwrap();
451 assert_eq!(config.links.len(), 2);
452
453 assert!(config.links[0].auth.is_some());
455 let auth1 = config.links[0].auth.as_ref().unwrap();
456 assert_eq!(auth1.create, "service_only");
457
458 assert!(config.links[1].auth.is_none());
460 }
461
462 #[test]
463 fn test_merge_empty() {
464 let merged = LinksConfig::merge(vec![]);
465 assert_eq!(merged.entities.len(), 0);
466 assert_eq!(merged.links.len(), 0);
467 }
468
469 #[test]
470 fn test_merge_single() {
471 let config = LinksConfig::default_config();
472 let merged = LinksConfig::merge(vec![config.clone()]);
473 assert_eq!(merged.entities.len(), config.entities.len());
474 assert_eq!(merged.links.len(), config.links.len());
475 }
476
477 #[test]
478 fn test_merge_disjoint_configs() {
479 let config1 = LinksConfig {
480 entities: vec![EntityConfig {
481 singular: "order".to_string(),
482 plural: "orders".to_string(),
483 auth: EntityAuthConfig::default(),
484 }],
485 links: vec![],
486 validation_rules: None,
487 events: None,
488 sinks: None,
489 };
490
491 let config2 = LinksConfig {
492 entities: vec![EntityConfig {
493 singular: "invoice".to_string(),
494 plural: "invoices".to_string(),
495 auth: EntityAuthConfig::default(),
496 }],
497 links: vec![],
498 validation_rules: None,
499 events: None,
500 sinks: None,
501 };
502
503 let merged = LinksConfig::merge(vec![config1, config2]);
504 assert_eq!(merged.entities.len(), 2);
505 }
506
507 #[test]
508 fn test_merge_overlapping_entities() {
509 let auth1 = EntityAuthConfig {
510 list: "public".to_string(),
511 ..Default::default()
512 };
513
514 let config1 = LinksConfig {
515 entities: vec![EntityConfig {
516 singular: "user".to_string(),
517 plural: "users".to_string(),
518 auth: auth1,
519 }],
520 links: vec![],
521 validation_rules: None,
522 events: None,
523 sinks: None,
524 };
525
526 let auth2 = EntityAuthConfig {
527 list: "authenticated".to_string(),
528 ..Default::default()
529 };
530
531 let config2 = LinksConfig {
532 entities: vec![EntityConfig {
533 singular: "user".to_string(),
534 plural: "users".to_string(),
535 auth: auth2,
536 }],
537 links: vec![],
538 validation_rules: None,
539 events: None,
540 sinks: None,
541 };
542
543 let merged = LinksConfig::merge(vec![config1, config2]);
544
545 assert_eq!(merged.entities.len(), 1);
547 assert_eq!(merged.entities[0].auth.list, "authenticated");
548 }
549
550 #[test]
551 fn test_merge_validation_rules() {
552 let mut rules1 = HashMap::new();
553 rules1.insert(
554 "works_at".to_string(),
555 vec![ValidationRule {
556 source: "user".to_string(),
557 targets: vec!["company".to_string()],
558 }],
559 );
560
561 let config1 = LinksConfig {
562 entities: vec![],
563 links: vec![],
564 validation_rules: Some(rules1),
565 events: None,
566 sinks: None,
567 };
568
569 let mut rules2 = HashMap::new();
570 rules2.insert(
571 "works_at".to_string(),
572 vec![ValidationRule {
573 source: "user".to_string(),
574 targets: vec!["project".to_string()],
575 }],
576 );
577
578 let config2 = LinksConfig {
579 entities: vec![],
580 links: vec![],
581 validation_rules: Some(rules2),
582 events: None,
583 sinks: None,
584 };
585
586 let merged = LinksConfig::merge(vec![config1, config2]);
587
588 assert!(merged.validation_rules.is_some());
590 let rules = merged.validation_rules.unwrap();
591 assert_eq!(rules["works_at"].len(), 2);
592 }
593
594 #[test]
595 fn test_find_link_definition_found() {
596 let config = LinksConfig::default_config();
597
598 let def = config.find_link_definition("owner", "user", "car");
599 assert!(def.is_some(), "should find owner link from user to car");
600 let def = def.expect("link definition should exist");
601 assert_eq!(def.link_type, "owner");
602 assert_eq!(def.source_type, "user");
603 assert_eq!(def.target_type, "car");
604 }
605
606 #[test]
607 fn test_find_link_definition_not_found() {
608 let config = LinksConfig::default_config();
609
610 let def = config.find_link_definition("nonexistent", "user", "car");
611 assert!(def.is_none(), "should not find a nonexistent link type");
612
613 let def = config.find_link_definition("owner", "company", "car");
615 assert!(def.is_none(), "should not find link with wrong source type");
616 }
617
618 #[test]
619 fn test_is_valid_link_source_type_mismatch() {
620 let mut rules = HashMap::new();
621 rules.insert(
622 "owner".to_string(),
623 vec![ValidationRule {
624 source: "user".to_string(),
625 targets: vec!["car".to_string()],
626 }],
627 );
628
629 let config = LinksConfig {
630 entities: vec![],
631 links: vec![],
632 validation_rules: Some(rules),
633 events: None,
634 sinks: None,
635 };
636
637 assert!(config.is_valid_link("owner", "user", "car"));
639
640 assert!(
642 !config.is_valid_link("owner", "company", "car"),
643 "should reject mismatched source type"
644 );
645
646 assert!(
648 !config.is_valid_link("owner", "user", "truck"),
649 "should reject mismatched target type"
650 );
651 }
652
653 #[test]
654 fn test_is_valid_link_empty_targets() {
655 let mut rules = HashMap::new();
656 rules.insert(
657 "membership".to_string(),
658 vec![ValidationRule {
659 source: "user".to_string(),
660 targets: vec![], }],
662 );
663
664 let config = LinksConfig {
665 entities: vec![],
666 links: vec![],
667 validation_rules: Some(rules),
668 events: None,
669 sinks: None,
670 };
671
672 assert!(
674 !config.is_valid_link("membership", "user", "group"),
675 "should reject when targets list is empty"
676 );
677 }
678
679 #[test]
680 fn test_yaml_backward_compatible_without_events() {
681 let yaml = r#"
683entities:
684 - singular: user
685 plural: users
686links:
687 - link_type: follows
688 source_type: user
689 target_type: user
690 forward_route_name: following
691 reverse_route_name: followers
692"#;
693
694 let config = LinksConfig::from_yaml_str(yaml).unwrap();
695 assert_eq!(config.entities.len(), 1);
696 assert_eq!(config.links.len(), 1);
697 assert!(config.events.is_none());
698 assert!(config.sinks.is_none());
699 }
700
701 #[test]
702 fn test_yaml_with_events_and_sinks() {
703 let yaml = r#"
704entities:
705 - singular: user
706 plural: users
707 - singular: capture
708 plural: captures
709
710links:
711 - link_type: follows
712 source_type: user
713 target_type: user
714 forward_route_name: following
715 reverse_route_name: followers
716 - link_type: likes
717 source_type: user
718 target_type: capture
719 forward_route_name: liked-captures
720 reverse_route_name: likers
721 - link_type: owns
722 source_type: user
723 target_type: capture
724 forward_route_name: captures
725 reverse_route_name: owner
726
727events:
728 backend:
729 type: memory
730 flows:
731 - name: notify-new-follower
732 trigger:
733 kind: link.created
734 link_type: follows
735 pipeline:
736 - resolve:
737 from: source_id
738 as: follower
739 - map:
740 template:
741 type: follow
742 message: "{{ follower.name }} started following you"
743 - deliver:
744 sinks: [push-notification, in-app-notification]
745 - name: notify-like
746 trigger:
747 kind: link.created
748 link_type: likes
749 pipeline:
750 - resolve:
751 from: target_id
752 via: owns
753 direction: reverse
754 as: owner
755 - filter:
756 condition: "source_id != owner.id"
757 - batch:
758 key: target_id
759 window: 5m
760 - deliver:
761 sink: push-notification
762 consumers:
763 - name: mobile-feed
764 seek: last_acknowledged
765
766sinks:
767 - name: push-notification
768 type: push
769 config:
770 provider: expo
771 - name: in-app-notification
772 type: in_app
773 config:
774 ttl: 30d
775"#;
776
777 let config = LinksConfig::from_yaml_str(yaml).unwrap();
778
779 assert_eq!(config.entities.len(), 2);
781 assert_eq!(config.links.len(), 3);
782
783 assert!(config.events.is_some());
785 let events = config.events.as_ref().unwrap();
786 assert_eq!(events.backend.backend_type, "memory");
787 assert_eq!(events.flows.len(), 2);
788 assert_eq!(events.flows[0].name, "notify-new-follower");
789 assert_eq!(events.flows[1].name, "notify-like");
790 assert_eq!(events.consumers.len(), 1);
791 assert_eq!(events.consumers[0].name, "mobile-feed");
792
793 assert!(config.sinks.is_some());
795 let sinks = config.sinks.as_ref().unwrap();
796 assert_eq!(sinks.len(), 2);
797 assert_eq!(sinks[0].name, "push-notification");
798 assert_eq!(sinks[0].sink_type, SinkType::Push);
799 assert_eq!(sinks[1].name, "in-app-notification");
800 assert_eq!(sinks[1].sink_type, SinkType::InApp);
801 }
802
803 #[test]
804 fn test_merge_configs_with_events() {
805 let config1 = LinksConfig {
806 entities: vec![EntityConfig {
807 singular: "user".to_string(),
808 plural: "users".to_string(),
809 auth: EntityAuthConfig::default(),
810 }],
811 links: vec![],
812 validation_rules: None,
813 events: Some(EventsConfig {
814 backend: BackendConfig::default(),
815 flows: vec![FlowConfig {
816 name: "flow-a".to_string(),
817 description: None,
818 trigger: TriggerConfig {
819 kind: "link.created".to_string(),
820 link_type: Some("follows".to_string()),
821 entity_type: None,
822 },
823 pipeline: vec![],
824 }],
825 consumers: vec![],
826 }),
827 sinks: Some(vec![SinkConfig {
828 name: "push".to_string(),
829 sink_type: SinkType::Push,
830 config: HashMap::new(),
831 }]),
832 };
833
834 let config2 = LinksConfig {
835 entities: vec![],
836 links: vec![],
837 validation_rules: None,
838 events: Some(EventsConfig {
839 backend: BackendConfig::default(),
840 flows: vec![FlowConfig {
841 name: "flow-b".to_string(),
842 description: None,
843 trigger: TriggerConfig {
844 kind: "entity.created".to_string(),
845 link_type: None,
846 entity_type: Some("user".to_string()),
847 },
848 pipeline: vec![],
849 }],
850 consumers: vec![ConsumerConfig {
851 name: "mobile".to_string(),
852 seek: SeekMode::LastAcknowledged,
853 }],
854 }),
855 sinks: Some(vec![SinkConfig {
856 name: "in-app".to_string(),
857 sink_type: SinkType::InApp,
858 config: HashMap::new(),
859 }]),
860 };
861
862 let merged = LinksConfig::merge(vec![config1, config2]);
863
864 let events = merged.events.unwrap();
866 assert_eq!(events.flows.len(), 2);
867 assert_eq!(events.flows[0].name, "flow-a");
868 assert_eq!(events.flows[1].name, "flow-b");
869 assert_eq!(events.consumers.len(), 1);
870
871 let sinks = merged.sinks.unwrap();
873 assert_eq!(sinks.len(), 2);
874 }
875}