1use serde::{Deserialize, Serialize};
24use std::collections::HashMap;
25
26use drasi_lib::DurabilityConfig;
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34pub struct HttpSourceConfig {
35 pub host: String,
37
38 pub port: u16,
40
41 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub endpoint: Option<String>,
44
45 #[serde(default = "default_timeout_ms")]
47 pub timeout_ms: u64,
48
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub adaptive_max_batch_size: Option<usize>,
52
53 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub adaptive_min_batch_size: Option<usize>,
56
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub adaptive_max_wait_ms: Option<u64>,
60
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub adaptive_min_wait_ms: Option<u64>,
64
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub adaptive_window_secs: Option<u64>,
68
69 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub adaptive_enabled: Option<bool>,
72
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub webhooks: Option<WebhookConfig>,
76
77 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub durability: Option<DurabilityConfig>,
84}
85
86impl HttpSourceConfig {
88 pub fn is_webhook_mode(&self) -> bool {
90 self.webhooks.is_some()
91 }
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
96pub struct WebhookConfig {
97 #[serde(default)]
99 pub error_behavior: ErrorBehavior,
100
101 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub cors: Option<CorsConfig>,
104
105 pub routes: Vec<WebhookRoute>,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
111pub struct CorsConfig {
112 #[serde(default = "default_cors_enabled")]
114 pub enabled: bool,
115
116 #[serde(default = "default_cors_origins")]
118 pub allow_origins: Vec<String>,
119
120 #[serde(default = "default_cors_methods")]
122 pub allow_methods: Vec<String>,
123
124 #[serde(default = "default_cors_headers")]
126 pub allow_headers: Vec<String>,
127
128 #[serde(default, skip_serializing_if = "Vec::is_empty")]
130 pub expose_headers: Vec<String>,
131
132 #[serde(default)]
134 pub allow_credentials: bool,
135
136 #[serde(default = "default_cors_max_age")]
138 pub max_age: u64,
139}
140
141fn default_cors_enabled() -> bool {
142 true
143}
144
145fn default_cors_origins() -> Vec<String> {
146 vec!["*".to_string()]
147}
148
149fn default_cors_methods() -> Vec<String> {
150 vec![
151 "GET".to_string(),
152 "POST".to_string(),
153 "PUT".to_string(),
154 "PATCH".to_string(),
155 "DELETE".to_string(),
156 "OPTIONS".to_string(),
157 ]
158}
159
160fn default_cors_headers() -> Vec<String> {
161 vec![
162 "Content-Type".to_string(),
163 "Authorization".to_string(),
164 "X-Requested-With".to_string(),
165 ]
166}
167
168fn default_cors_max_age() -> u64 {
169 3600
170}
171
172impl Default for CorsConfig {
173 fn default() -> Self {
174 Self {
175 enabled: true,
176 allow_origins: default_cors_origins(),
177 allow_methods: default_cors_methods(),
178 allow_headers: default_cors_headers(),
179 expose_headers: Vec::new(),
180 allow_credentials: false,
181 max_age: default_cors_max_age(),
182 }
183 }
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
188#[serde(rename_all = "snake_case")]
189pub enum ErrorBehavior {
190 #[default]
192 AcceptAndLog,
193 AcceptAndSkip,
195 Reject,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
201pub struct WebhookRoute {
202 pub path: String,
205
206 #[serde(default = "default_methods")]
208 pub methods: Vec<HttpMethod>,
209
210 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub auth: Option<AuthConfig>,
213
214 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub error_behavior: Option<ErrorBehavior>,
217
218 pub mappings: Vec<WebhookMapping>,
220}
221
222fn default_methods() -> Vec<HttpMethod> {
223 vec![HttpMethod::Post]
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
228#[serde(rename_all = "UPPERCASE")]
229pub enum HttpMethod {
230 Get,
231 Post,
232 Put,
233 Patch,
234 Delete,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
239pub struct AuthConfig {
240 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub signature: Option<SignatureConfig>,
243
244 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub bearer: Option<BearerConfig>,
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
251pub struct SignatureConfig {
252 #[serde(rename = "type")]
254 pub algorithm: SignatureAlgorithm,
255
256 pub secret_env: String,
258
259 pub header: String,
261
262 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub prefix: Option<String>,
265
266 #[serde(default)]
268 pub encoding: SignatureEncoding,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
273#[serde(rename_all = "kebab-case")]
274pub enum SignatureAlgorithm {
275 HmacSha1,
276 HmacSha256,
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
281#[serde(rename_all = "lowercase")]
282pub enum SignatureEncoding {
283 #[default]
284 Hex,
285 Base64,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
290pub struct BearerConfig {
291 pub token_env: String,
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
297pub struct WebhookMapping {
298 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub when: Option<MappingCondition>,
301
302 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub operation: Option<OperationType>,
305
306 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub operation_from: Option<String>,
309
310 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub operation_map: Option<HashMap<String, OperationType>>,
313
314 pub element_type: ElementType,
316
317 #[serde(default, skip_serializing_if = "Option::is_none")]
319 pub effective_from: Option<EffectiveFromConfig>,
320
321 pub template: ElementTemplate,
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
327pub struct MappingCondition {
328 #[serde(default, skip_serializing_if = "Option::is_none")]
330 pub header: Option<String>,
331
332 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub field: Option<String>,
335
336 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub equals: Option<String>,
339
340 #[serde(default, skip_serializing_if = "Option::is_none")]
342 pub contains: Option<String>,
343
344 #[serde(default, skip_serializing_if = "Option::is_none")]
346 pub regex: Option<String>,
347}
348
349#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
351#[serde(rename_all = "lowercase")]
352pub enum OperationType {
353 Insert,
354 Update,
355 Delete,
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
360#[serde(rename_all = "lowercase")]
361pub enum ElementType {
362 Node,
363 Relation,
364}
365
366#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
368#[serde(untagged)]
369pub enum EffectiveFromConfig {
370 Simple(String),
372 Explicit {
374 value: String,
376 format: TimestampFormat,
378 },
379}
380
381#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
383#[serde(rename_all = "snake_case")]
384pub enum TimestampFormat {
385 Iso8601,
387 UnixSeconds,
389 UnixMillis,
391 UnixNanos,
393}
394
395#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
397pub struct ElementTemplate {
398 pub id: String,
400
401 pub labels: Vec<String>,
403
404 #[serde(default, skip_serializing_if = "Option::is_none")]
406 pub properties: Option<serde_json::Value>,
407
408 #[serde(default, skip_serializing_if = "Option::is_none")]
410 pub from: Option<String>,
411
412 #[serde(default, skip_serializing_if = "Option::is_none")]
414 pub to: Option<String>,
415}
416
417fn default_timeout_ms() -> u64 {
418 10000
419}
420
421impl HttpSourceConfig {
422 pub fn validate(&self) -> anyhow::Result<()> {
431 if self.port == 0 {
432 return Err(anyhow::anyhow!(
433 "Validation error: port cannot be 0. \
434 Please specify a valid port number (1-65535)"
435 ));
436 }
437
438 if self.timeout_ms == 0 {
439 return Err(anyhow::anyhow!(
440 "Validation error: timeout_ms cannot be 0. \
441 Please specify a positive timeout value in milliseconds"
442 ));
443 }
444
445 if let (Some(min), Some(max)) = (self.adaptive_min_batch_size, self.adaptive_max_batch_size)
447 {
448 if min > max {
449 return Err(anyhow::anyhow!(
450 "Validation error: adaptive_min_batch_size ({min}) cannot be greater than \
451 adaptive_max_batch_size ({max})"
452 ));
453 }
454 }
455
456 if let (Some(min), Some(max)) = (self.adaptive_min_wait_ms, self.adaptive_max_wait_ms) {
457 if min > max {
458 return Err(anyhow::anyhow!(
459 "Validation error: adaptive_min_wait_ms ({min}) cannot be greater than \
460 adaptive_max_wait_ms ({max})"
461 ));
462 }
463 }
464
465 if let Some(ref webhooks) = self.webhooks {
467 webhooks.validate()?;
468 }
469
470 Ok(())
471 }
472}
473
474impl WebhookConfig {
475 pub fn validate(&self) -> anyhow::Result<()> {
477 if self.routes.is_empty() {
478 return Err(anyhow::anyhow!(
479 "Validation error: webhooks.routes cannot be empty"
480 ));
481 }
482
483 for (idx, route) in self.routes.iter().enumerate() {
484 route
485 .validate()
486 .map_err(|e| anyhow::anyhow!("Validation error in route[{idx}]: {e}"))?;
487 }
488
489 Ok(())
490 }
491}
492
493impl WebhookRoute {
494 pub fn validate(&self) -> anyhow::Result<()> {
496 if self.path.is_empty() {
497 return Err(anyhow::anyhow!("path cannot be empty"));
498 }
499
500 if !self.path.starts_with('/') {
501 return Err(anyhow::anyhow!("path must start with '/'"));
502 }
503
504 if self.methods.is_empty() {
505 return Err(anyhow::anyhow!("methods cannot be empty"));
506 }
507
508 if self.mappings.is_empty() {
509 return Err(anyhow::anyhow!("mappings cannot be empty"));
510 }
511
512 for (idx, mapping) in self.mappings.iter().enumerate() {
513 mapping
514 .validate()
515 .map_err(|e| anyhow::anyhow!("mappings[{idx}]: {e}"))?;
516 }
517
518 Ok(())
519 }
520}
521
522impl WebhookMapping {
523 pub fn validate(&self) -> anyhow::Result<()> {
525 if self.operation.is_none() && self.operation_from.is_none() {
527 return Err(anyhow::anyhow!(
528 "either 'operation' or 'operation_from' must be specified"
529 ));
530 }
531
532 if self.operation_from.is_some() && self.operation_map.is_none() {
534 return Err(anyhow::anyhow!(
535 "'operation_map' is required when using 'operation_from'"
536 ));
537 }
538
539 self.template.validate(&self.element_type)?;
541
542 Ok(())
543 }
544}
545
546impl ElementTemplate {
547 pub fn validate(&self, element_type: &ElementType) -> anyhow::Result<()> {
549 if self.id.is_empty() {
550 return Err(anyhow::anyhow!("template.id cannot be empty"));
551 }
552
553 if self.labels.is_empty() {
554 return Err(anyhow::anyhow!("template.labels cannot be empty"));
555 }
556
557 if *element_type == ElementType::Relation {
559 if self.from.is_none() {
560 return Err(anyhow::anyhow!(
561 "template.from is required for relation elements"
562 ));
563 }
564 if self.to.is_none() {
565 return Err(anyhow::anyhow!(
566 "template.to is required for relation elements"
567 ));
568 }
569 }
570
571 Ok(())
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578
579 #[test]
580 fn test_config_deserialization_minimal() {
581 let yaml = r#"
582host: "localhost"
583port: 8080
584"#;
585 let config: HttpSourceConfig = serde_yaml::from_str(yaml).unwrap();
586 assert_eq!(config.host, "localhost");
587 assert_eq!(config.port, 8080);
588 assert_eq!(config.endpoint, None);
589 assert_eq!(config.timeout_ms, 10000); assert_eq!(config.adaptive_enabled, None);
591 }
592
593 #[test]
594 fn test_config_deserialization_full() {
595 let yaml = r#"
596host: "0.0.0.0"
597port: 9000
598endpoint: "/events"
599timeout_ms: 5000
600adaptive_max_batch_size: 1000
601adaptive_min_batch_size: 10
602adaptive_max_wait_ms: 500
603adaptive_min_wait_ms: 10
604adaptive_window_secs: 60
605adaptive_enabled: true
606"#;
607 let config: HttpSourceConfig = serde_yaml::from_str(yaml).unwrap();
608 assert_eq!(config.host, "0.0.0.0");
609 assert_eq!(config.port, 9000);
610 assert_eq!(config.endpoint, Some("/events".to_string()));
611 assert_eq!(config.timeout_ms, 5000);
612 assert_eq!(config.adaptive_max_batch_size, Some(1000));
613 assert_eq!(config.adaptive_min_batch_size, Some(10));
614 assert_eq!(config.adaptive_max_wait_ms, Some(500));
615 assert_eq!(config.adaptive_min_wait_ms, Some(10));
616 assert_eq!(config.adaptive_window_secs, Some(60));
617 assert_eq!(config.adaptive_enabled, Some(true));
618 }
619
620 #[test]
621 fn test_config_serialization() {
622 let config = HttpSourceConfig {
623 host: "localhost".to_string(),
624 port: 8080,
625 endpoint: Some("/data".to_string()),
626 timeout_ms: 15000,
627 adaptive_max_batch_size: Some(500),
628 adaptive_min_batch_size: Some(5),
629 adaptive_max_wait_ms: Some(1000),
630 adaptive_min_wait_ms: Some(50),
631 adaptive_window_secs: Some(30),
632 adaptive_enabled: Some(false),
633 webhooks: None,
634 durability: None,
635 };
636
637 let yaml = serde_yaml::to_string(&config).unwrap();
638 let deserialized: HttpSourceConfig = serde_yaml::from_str(&yaml).unwrap();
639 assert_eq!(config, deserialized);
640 }
641
642 #[test]
643 fn test_config_adaptive_batching_disabled() {
644 let yaml = r#"
645host: "localhost"
646port: 8080
647adaptive_enabled: false
648"#;
649 let config: HttpSourceConfig = serde_yaml::from_str(yaml).unwrap();
650 assert_eq!(config.adaptive_enabled, Some(false));
651 }
652
653 #[test]
654 fn test_config_default_values() {
655 let config = HttpSourceConfig {
656 host: "localhost".to_string(),
657 port: 8080,
658 endpoint: None,
659 timeout_ms: default_timeout_ms(),
660 adaptive_max_batch_size: None,
661 adaptive_min_batch_size: None,
662 adaptive_max_wait_ms: None,
663 adaptive_min_wait_ms: None,
664 adaptive_window_secs: None,
665 adaptive_enabled: None,
666 webhooks: None,
667 durability: None,
668 };
669
670 assert_eq!(config.timeout_ms, 10000);
671 }
672
673 #[test]
674 fn test_config_port_range() {
675 let yaml = r#"
677host: "localhost"
678port: 65535
679"#;
680 let config: HttpSourceConfig = serde_yaml::from_str(yaml).unwrap();
681 assert_eq!(config.port, 65535);
682
683 let yaml = r#"
685host: "localhost"
686port: 1
687"#;
688 let config: HttpSourceConfig = serde_yaml::from_str(yaml).unwrap();
689 assert_eq!(config.port, 1);
690 }
691
692 #[test]
693 fn test_webhook_config_deserialization() {
694 let yaml = r#"
695host: "0.0.0.0"
696port: 8080
697webhooks:
698 error_behavior: reject
699 routes:
700 - path: "/github/events"
701 methods: ["POST"]
702 auth:
703 signature:
704 type: hmac-sha256
705 secret_env: GITHUB_SECRET
706 header: X-Hub-Signature-256
707 prefix: "sha256="
708 bearer:
709 token_env: GITHUB_TOKEN
710 error_behavior: reject
711 mappings:
712 - when:
713 header: X-GitHub-Event
714 equals: push
715 operation: insert
716 element_type: node
717 effective_from: "{{payload.timestamp}}"
718 template:
719 id: "commit-{{payload.id}}"
720 labels: ["Commit"]
721 properties:
722 message: "{{payload.message}}"
723"#;
724 let config: HttpSourceConfig = serde_yaml::from_str(yaml).unwrap();
725 assert!(config.webhooks.is_some());
726 let webhooks = config.webhooks.unwrap();
727 assert_eq!(webhooks.error_behavior, ErrorBehavior::Reject);
728 assert_eq!(webhooks.routes.len(), 1);
729
730 let route = &webhooks.routes[0];
731 assert_eq!(route.path, "/github/events");
732 assert_eq!(route.methods, vec![HttpMethod::Post]);
733 assert!(route.auth.is_some());
734
735 let auth = route.auth.as_ref().unwrap();
736 assert!(auth.signature.is_some());
737 assert!(auth.bearer.is_some());
738
739 let sig = auth.signature.as_ref().unwrap();
740 assert_eq!(sig.algorithm, SignatureAlgorithm::HmacSha256);
741 assert_eq!(sig.secret_env, "GITHUB_SECRET");
742 assert_eq!(sig.header, "X-Hub-Signature-256");
743 assert_eq!(sig.prefix, Some("sha256=".to_string()));
744
745 let mapping = &route.mappings[0];
746 assert!(mapping.when.is_some());
747 assert_eq!(mapping.operation, Some(OperationType::Insert));
748 assert_eq!(mapping.element_type, ElementType::Node);
749 }
750
751 #[test]
752 fn test_webhook_config_with_operation_map() {
753 let yaml = r#"
754host: "0.0.0.0"
755port: 8080
756webhooks:
757 routes:
758 - path: "/events"
759 mappings:
760 - operation_from: "payload.action"
761 operation_map:
762 created: insert
763 updated: update
764 deleted: delete
765 element_type: node
766 template:
767 id: "{{payload.id}}"
768 labels: ["Event"]
769"#;
770 let config: HttpSourceConfig = serde_yaml::from_str(yaml).unwrap();
771 let webhooks = config.webhooks.unwrap();
772 let mapping = &webhooks.routes[0].mappings[0];
773
774 assert_eq!(mapping.operation_from, Some("payload.action".to_string()));
775 assert!(mapping.operation_map.is_some());
776 let op_map = mapping.operation_map.as_ref().unwrap();
777 assert_eq!(op_map.get("created"), Some(&OperationType::Insert));
778 assert_eq!(op_map.get("updated"), Some(&OperationType::Update));
779 assert_eq!(op_map.get("deleted"), Some(&OperationType::Delete));
780 }
781
782 #[test]
783 fn test_webhook_config_relation() {
784 let yaml = r#"
785host: "0.0.0.0"
786port: 8080
787webhooks:
788 routes:
789 - path: "/links"
790 mappings:
791 - operation: insert
792 element_type: relation
793 template:
794 id: "{{payload.id}}"
795 labels: ["LINKS_TO"]
796 from: "{{payload.source_id}}"
797 to: "{{payload.target_id}}"
798"#;
799 let config: HttpSourceConfig = serde_yaml::from_str(yaml).unwrap();
800 let mapping = &config.webhooks.unwrap().routes[0].mappings[0];
801 assert_eq!(mapping.element_type, ElementType::Relation);
802 assert_eq!(
803 mapping.template.from,
804 Some("{{payload.source_id}}".to_string())
805 );
806 assert_eq!(
807 mapping.template.to,
808 Some("{{payload.target_id}}".to_string())
809 );
810 }
811
812 #[test]
813 fn test_effective_from_simple() {
814 let yaml = r#"
815host: "0.0.0.0"
816port: 8080
817webhooks:
818 routes:
819 - path: "/events"
820 mappings:
821 - operation: insert
822 element_type: node
823 effective_from: "{{payload.timestamp}}"
824 template:
825 id: "{{payload.id}}"
826 labels: ["Event"]
827"#;
828 let config: HttpSourceConfig = serde_yaml::from_str(yaml).unwrap();
829 let mapping = &config.webhooks.unwrap().routes[0].mappings[0];
830 assert_eq!(
831 mapping.effective_from,
832 Some(EffectiveFromConfig::Simple(
833 "{{payload.timestamp}}".to_string()
834 ))
835 );
836 }
837
838 #[test]
839 fn test_effective_from_explicit() {
840 let yaml = r#"
841host: "0.0.0.0"
842port: 8080
843webhooks:
844 routes:
845 - path: "/events"
846 mappings:
847 - operation: insert
848 element_type: node
849 effective_from:
850 value: "{{payload.created_at}}"
851 format: iso8601
852 template:
853 id: "{{payload.id}}"
854 labels: ["Event"]
855"#;
856 let config: HttpSourceConfig = serde_yaml::from_str(yaml).unwrap();
857 let mapping = &config.webhooks.unwrap().routes[0].mappings[0];
858 match &mapping.effective_from {
859 Some(EffectiveFromConfig::Explicit { value, format }) => {
860 assert_eq!(value, "{{payload.created_at}}");
861 assert_eq!(*format, TimestampFormat::Iso8601);
862 }
863 _ => panic!("Expected explicit effective_from config"),
864 }
865 }
866
867 #[test]
868 fn test_is_webhook_mode() {
869 let yaml_standard = r#"
870host: "localhost"
871port: 8080
872"#;
873 let config: HttpSourceConfig = serde_yaml::from_str(yaml_standard).unwrap();
874 assert!(!config.is_webhook_mode());
875
876 let yaml_webhook = r#"
877host: "localhost"
878port: 8080
879webhooks:
880 routes:
881 - path: "/events"
882 mappings:
883 - operation: insert
884 element_type: node
885 template:
886 id: "{{payload.id}}"
887 labels: ["Event"]
888"#;
889 let config: HttpSourceConfig = serde_yaml::from_str(yaml_webhook).unwrap();
890 assert!(config.is_webhook_mode());
891 }
892
893 #[test]
894 fn test_webhook_validation_empty_routes() {
895 let yaml = r#"
896host: "localhost"
897port: 8080
898webhooks:
899 routes: []
900"#;
901 let config: HttpSourceConfig = serde_yaml::from_str(yaml).unwrap();
902 assert!(config.validate().is_err());
903 }
904
905 #[test]
906 fn test_webhook_validation_missing_operation() {
907 let yaml = r#"
908host: "localhost"
909port: 8080
910webhooks:
911 routes:
912 - path: "/events"
913 mappings:
914 - element_type: node
915 template:
916 id: "{{payload.id}}"
917 labels: ["Event"]
918"#;
919 let config: HttpSourceConfig = serde_yaml::from_str(yaml).unwrap();
920 assert!(config.validate().is_err());
921 }
922
923 #[test]
924 fn test_webhook_validation_relation_missing_from_to() {
925 let yaml = r#"
926host: "localhost"
927port: 8080
928webhooks:
929 routes:
930 - path: "/events"
931 mappings:
932 - operation: insert
933 element_type: relation
934 template:
935 id: "{{payload.id}}"
936 labels: ["LINKS"]
937"#;
938 let config: HttpSourceConfig = serde_yaml::from_str(yaml).unwrap();
939 assert!(config.validate().is_err());
940 }
941}