1use serde::{Deserialize, Serialize};
24
25use drasi_lib::DurabilityConfig;
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
33pub struct HttpSourceConfig {
34 pub host: String,
36
37 pub port: u16,
39
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub endpoint: Option<String>,
43
44 #[serde(default = "default_timeout_ms")]
46 pub timeout_ms: u64,
47
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub adaptive_max_batch_size: Option<usize>,
51
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub adaptive_min_batch_size: Option<usize>,
55
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub adaptive_max_wait_ms: Option<u64>,
59
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub adaptive_min_wait_ms: Option<u64>,
63
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub adaptive_window_secs: Option<u64>,
67
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub adaptive_enabled: Option<bool>,
71
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub webhooks: Option<WebhookConfig>,
75
76 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub durability: Option<DurabilityConfig>,
83}
84
85impl HttpSourceConfig {
87 pub fn is_webhook_mode(&self) -> bool {
89 self.webhooks.is_some()
90 }
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
95pub struct WebhookConfig {
96 #[serde(default)]
98 pub error_behavior: ErrorBehavior,
99
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub cors: Option<CorsConfig>,
103
104 pub routes: Vec<WebhookRoute>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
110pub struct CorsConfig {
111 #[serde(default = "default_cors_enabled")]
113 pub enabled: bool,
114
115 #[serde(default = "default_cors_origins")]
117 pub allow_origins: Vec<String>,
118
119 #[serde(default = "default_cors_methods")]
121 pub allow_methods: Vec<String>,
122
123 #[serde(default = "default_cors_headers")]
125 pub allow_headers: Vec<String>,
126
127 #[serde(default, skip_serializing_if = "Vec::is_empty")]
129 pub expose_headers: Vec<String>,
130
131 #[serde(default)]
133 pub allow_credentials: bool,
134
135 #[serde(default = "default_cors_max_age")]
137 pub max_age: u64,
138}
139
140fn default_cors_enabled() -> bool {
141 true
142}
143
144fn default_cors_origins() -> Vec<String> {
145 vec!["*".to_string()]
146}
147
148fn default_cors_methods() -> Vec<String> {
149 vec![
150 "GET".to_string(),
151 "POST".to_string(),
152 "PUT".to_string(),
153 "PATCH".to_string(),
154 "DELETE".to_string(),
155 "OPTIONS".to_string(),
156 ]
157}
158
159fn default_cors_headers() -> Vec<String> {
160 vec![
161 "Content-Type".to_string(),
162 "Authorization".to_string(),
163 "X-Requested-With".to_string(),
164 ]
165}
166
167fn default_cors_max_age() -> u64 {
168 3600
169}
170
171impl Default for CorsConfig {
172 fn default() -> Self {
173 Self {
174 enabled: true,
175 allow_origins: default_cors_origins(),
176 allow_methods: default_cors_methods(),
177 allow_headers: default_cors_headers(),
178 expose_headers: Vec::new(),
179 allow_credentials: false,
180 max_age: default_cors_max_age(),
181 }
182 }
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
187#[serde(rename_all = "snake_case")]
188pub enum ErrorBehavior {
189 #[default]
191 AcceptAndLog,
192 AcceptAndSkip,
194 Reject,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
200pub struct WebhookRoute {
201 pub path: String,
204
205 #[serde(default = "default_methods")]
207 pub methods: Vec<HttpMethod>,
208
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub auth: Option<AuthConfig>,
212
213 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub error_behavior: Option<ErrorBehavior>,
216
217 pub mappings: Vec<WebhookMapping>,
219}
220
221fn default_methods() -> Vec<HttpMethod> {
222 vec![HttpMethod::Post]
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
227#[serde(rename_all = "UPPERCASE")]
228pub enum HttpMethod {
229 Get,
230 Post,
231 Put,
232 Patch,
233 Delete,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
238pub struct AuthConfig {
239 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub signature: Option<SignatureConfig>,
242
243 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub bearer: Option<BearerConfig>,
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
250pub struct SignatureConfig {
251 #[serde(rename = "type")]
253 pub algorithm: SignatureAlgorithm,
254
255 pub secret_env: String,
257
258 pub header: String,
260
261 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub prefix: Option<String>,
264
265 #[serde(default)]
267 pub encoding: SignatureEncoding,
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
272#[serde(rename_all = "kebab-case")]
273pub enum SignatureAlgorithm {
274 HmacSha1,
275 HmacSha256,
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
280#[serde(rename_all = "lowercase")]
281pub enum SignatureEncoding {
282 #[default]
283 Hex,
284 Base64,
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
289pub struct BearerConfig {
290 pub token_env: String,
292}
293
294pub use drasi_source_mapping::EffectiveFromConfig;
297pub use drasi_source_mapping::ElementTemplate;
298pub use drasi_source_mapping::ElementType;
299pub use drasi_source_mapping::MappingCondition;
300pub use drasi_source_mapping::OperationType;
301pub use drasi_source_mapping::SourceMapping as WebhookMapping;
302pub use drasi_source_mapping::TimestampFormat;
303
304fn default_timeout_ms() -> u64 {
305 10000
306}
307
308impl HttpSourceConfig {
309 pub fn validate(&self) -> anyhow::Result<()> {
318 if self.port == 0 {
319 return Err(anyhow::anyhow!(
320 "Validation error: port cannot be 0. \
321 Please specify a valid port number (1-65535)"
322 ));
323 }
324
325 if self.timeout_ms == 0 {
326 return Err(anyhow::anyhow!(
327 "Validation error: timeout_ms cannot be 0. \
328 Please specify a positive timeout value in milliseconds"
329 ));
330 }
331
332 if let (Some(min), Some(max)) = (self.adaptive_min_batch_size, self.adaptive_max_batch_size)
334 {
335 if min > max {
336 return Err(anyhow::anyhow!(
337 "Validation error: adaptive_min_batch_size ({min}) cannot be greater than \
338 adaptive_max_batch_size ({max})"
339 ));
340 }
341 }
342
343 if let (Some(min), Some(max)) = (self.adaptive_min_wait_ms, self.adaptive_max_wait_ms) {
344 if min > max {
345 return Err(anyhow::anyhow!(
346 "Validation error: adaptive_min_wait_ms ({min}) cannot be greater than \
347 adaptive_max_wait_ms ({max})"
348 ));
349 }
350 }
351
352 if let Some(ref webhooks) = self.webhooks {
354 webhooks.validate()?;
355 }
356
357 Ok(())
358 }
359}
360
361impl WebhookConfig {
362 pub fn validate(&self) -> anyhow::Result<()> {
364 if self.routes.is_empty() {
365 return Err(anyhow::anyhow!(
366 "Validation error: webhooks.routes cannot be empty"
367 ));
368 }
369
370 for (idx, route) in self.routes.iter().enumerate() {
371 route
372 .validate()
373 .map_err(|e| anyhow::anyhow!("Validation error in route[{idx}]: {e}"))?;
374 }
375
376 Ok(())
377 }
378}
379
380impl WebhookRoute {
381 pub fn validate(&self) -> anyhow::Result<()> {
383 if self.path.is_empty() {
384 return Err(anyhow::anyhow!("path cannot be empty"));
385 }
386
387 if !self.path.starts_with('/') {
388 return Err(anyhow::anyhow!("path must start with '/'"));
389 }
390
391 if self.methods.is_empty() {
392 return Err(anyhow::anyhow!("methods cannot be empty"));
393 }
394
395 if self.mappings.is_empty() {
396 return Err(anyhow::anyhow!("mappings cannot be empty"));
397 }
398
399 for (idx, mapping) in self.mappings.iter().enumerate() {
400 mapping
401 .validate()
402 .map_err(|e| anyhow::anyhow!("mappings[{idx}]: {e}"))?;
403 }
404
405 Ok(())
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 #[test]
414 fn test_config_deserialization_minimal() {
415 let yaml = r#"
416host: "localhost"
417port: 8080
418"#;
419 let config: HttpSourceConfig = serde_yaml::from_str(yaml).unwrap();
420 assert_eq!(config.host, "localhost");
421 assert_eq!(config.port, 8080);
422 assert_eq!(config.endpoint, None);
423 assert_eq!(config.timeout_ms, 10000); assert_eq!(config.adaptive_enabled, None);
425 }
426
427 #[test]
428 fn test_config_deserialization_full() {
429 let yaml = r#"
430host: "0.0.0.0"
431port: 9000
432endpoint: "/events"
433timeout_ms: 5000
434adaptive_max_batch_size: 1000
435adaptive_min_batch_size: 10
436adaptive_max_wait_ms: 500
437adaptive_min_wait_ms: 10
438adaptive_window_secs: 60
439adaptive_enabled: true
440"#;
441 let config: HttpSourceConfig = serde_yaml::from_str(yaml).unwrap();
442 assert_eq!(config.host, "0.0.0.0");
443 assert_eq!(config.port, 9000);
444 assert_eq!(config.endpoint, Some("/events".to_string()));
445 assert_eq!(config.timeout_ms, 5000);
446 assert_eq!(config.adaptive_max_batch_size, Some(1000));
447 assert_eq!(config.adaptive_min_batch_size, Some(10));
448 assert_eq!(config.adaptive_max_wait_ms, Some(500));
449 assert_eq!(config.adaptive_min_wait_ms, Some(10));
450 assert_eq!(config.adaptive_window_secs, Some(60));
451 assert_eq!(config.adaptive_enabled, Some(true));
452 }
453
454 #[test]
455 fn test_config_serialization() {
456 let config = HttpSourceConfig {
457 host: "localhost".to_string(),
458 port: 8080,
459 endpoint: Some("/data".to_string()),
460 timeout_ms: 15000,
461 adaptive_max_batch_size: Some(500),
462 adaptive_min_batch_size: Some(5),
463 adaptive_max_wait_ms: Some(1000),
464 adaptive_min_wait_ms: Some(50),
465 adaptive_window_secs: Some(30),
466 adaptive_enabled: Some(false),
467 webhooks: None,
468 durability: None,
469 };
470
471 let yaml = serde_yaml::to_string(&config).unwrap();
472 let deserialized: HttpSourceConfig = serde_yaml::from_str(&yaml).unwrap();
473 assert_eq!(config, deserialized);
474 }
475
476 #[test]
477 fn test_config_adaptive_batching_disabled() {
478 let yaml = r#"
479host: "localhost"
480port: 8080
481adaptive_enabled: false
482"#;
483 let config: HttpSourceConfig = serde_yaml::from_str(yaml).unwrap();
484 assert_eq!(config.adaptive_enabled, Some(false));
485 }
486
487 #[test]
488 fn test_config_default_values() {
489 let config = HttpSourceConfig {
490 host: "localhost".to_string(),
491 port: 8080,
492 endpoint: None,
493 timeout_ms: default_timeout_ms(),
494 adaptive_max_batch_size: None,
495 adaptive_min_batch_size: None,
496 adaptive_max_wait_ms: None,
497 adaptive_min_wait_ms: None,
498 adaptive_window_secs: None,
499 adaptive_enabled: None,
500 webhooks: None,
501 durability: None,
502 };
503
504 assert_eq!(config.timeout_ms, 10000);
505 }
506
507 #[test]
508 fn test_config_port_range() {
509 let yaml = r#"
511host: "localhost"
512port: 65535
513"#;
514 let config: HttpSourceConfig = serde_yaml::from_str(yaml).unwrap();
515 assert_eq!(config.port, 65535);
516
517 let yaml = r#"
519host: "localhost"
520port: 1
521"#;
522 let config: HttpSourceConfig = serde_yaml::from_str(yaml).unwrap();
523 assert_eq!(config.port, 1);
524 }
525
526 #[test]
527 fn test_webhook_config_deserialization() {
528 let yaml = r#"
529host: "0.0.0.0"
530port: 8080
531webhooks:
532 error_behavior: reject
533 routes:
534 - path: "/github/events"
535 methods: ["POST"]
536 auth:
537 signature:
538 type: hmac-sha256
539 secret_env: GITHUB_SECRET
540 header: X-Hub-Signature-256
541 prefix: "sha256="
542 bearer:
543 token_env: GITHUB_TOKEN
544 error_behavior: reject
545 mappings:
546 - when:
547 header: X-GitHub-Event
548 equals: push
549 operation: insert
550 element_type: node
551 effective_from: "{{payload.timestamp}}"
552 template:
553 id: "commit-{{payload.id}}"
554 labels: ["Commit"]
555 properties:
556 message: "{{payload.message}}"
557"#;
558 let config: HttpSourceConfig = serde_yaml::from_str(yaml).unwrap();
559 assert!(config.webhooks.is_some());
560 let webhooks = config.webhooks.unwrap();
561 assert_eq!(webhooks.error_behavior, ErrorBehavior::Reject);
562 assert_eq!(webhooks.routes.len(), 1);
563
564 let route = &webhooks.routes[0];
565 assert_eq!(route.path, "/github/events");
566 assert_eq!(route.methods, vec![HttpMethod::Post]);
567 assert!(route.auth.is_some());
568
569 let auth = route.auth.as_ref().unwrap();
570 assert!(auth.signature.is_some());
571 assert!(auth.bearer.is_some());
572
573 let sig = auth.signature.as_ref().unwrap();
574 assert_eq!(sig.algorithm, SignatureAlgorithm::HmacSha256);
575 assert_eq!(sig.secret_env, "GITHUB_SECRET");
576 assert_eq!(sig.header, "X-Hub-Signature-256");
577 assert_eq!(sig.prefix, Some("sha256=".to_string()));
578
579 let mapping = &route.mappings[0];
580 assert!(mapping.when.is_some());
581 assert_eq!(mapping.operation, Some(OperationType::Insert));
582 assert_eq!(mapping.element_type, ElementType::Node);
583 }
584
585 #[test]
586 fn test_webhook_config_with_operation_map() {
587 let yaml = r#"
588host: "0.0.0.0"
589port: 8080
590webhooks:
591 routes:
592 - path: "/events"
593 mappings:
594 - operation_from: "payload.action"
595 operation_map:
596 created: insert
597 updated: update
598 deleted: delete
599 element_type: node
600 template:
601 id: "{{payload.id}}"
602 labels: ["Event"]
603"#;
604 let config: HttpSourceConfig = serde_yaml::from_str(yaml).unwrap();
605 let webhooks = config.webhooks.unwrap();
606 let mapping = &webhooks.routes[0].mappings[0];
607
608 assert_eq!(mapping.operation_from, Some("payload.action".to_string()));
609 assert!(mapping.operation_map.is_some());
610 let op_map = mapping.operation_map.as_ref().unwrap();
611 assert_eq!(op_map.get("created"), Some(&OperationType::Insert));
612 assert_eq!(op_map.get("updated"), Some(&OperationType::Update));
613 assert_eq!(op_map.get("deleted"), Some(&OperationType::Delete));
614 }
615
616 #[test]
617 fn test_webhook_config_relation() {
618 let yaml = r#"
619host: "0.0.0.0"
620port: 8080
621webhooks:
622 routes:
623 - path: "/links"
624 mappings:
625 - operation: insert
626 element_type: relation
627 template:
628 id: "{{payload.id}}"
629 labels: ["LINKS_TO"]
630 from: "{{payload.source_id}}"
631 to: "{{payload.target_id}}"
632"#;
633 let config: HttpSourceConfig = serde_yaml::from_str(yaml).unwrap();
634 let mapping = &config.webhooks.unwrap().routes[0].mappings[0];
635 assert_eq!(mapping.element_type, ElementType::Relation);
636 assert_eq!(
637 mapping.template.from,
638 Some("{{payload.source_id}}".to_string())
639 );
640 assert_eq!(
641 mapping.template.to,
642 Some("{{payload.target_id}}".to_string())
643 );
644 }
645
646 #[test]
647 fn test_effective_from_simple() {
648 let yaml = r#"
649host: "0.0.0.0"
650port: 8080
651webhooks:
652 routes:
653 - path: "/events"
654 mappings:
655 - operation: insert
656 element_type: node
657 effective_from: "{{payload.timestamp}}"
658 template:
659 id: "{{payload.id}}"
660 labels: ["Event"]
661"#;
662 let config: HttpSourceConfig = serde_yaml::from_str(yaml).unwrap();
663 let mapping = &config.webhooks.unwrap().routes[0].mappings[0];
664 assert_eq!(
665 mapping.effective_from,
666 Some(EffectiveFromConfig::Simple(
667 "{{payload.timestamp}}".to_string()
668 ))
669 );
670 }
671
672 #[test]
673 fn test_effective_from_explicit() {
674 let yaml = r#"
675host: "0.0.0.0"
676port: 8080
677webhooks:
678 routes:
679 - path: "/events"
680 mappings:
681 - operation: insert
682 element_type: node
683 effective_from:
684 value: "{{payload.created_at}}"
685 format: iso8601
686 template:
687 id: "{{payload.id}}"
688 labels: ["Event"]
689"#;
690 let config: HttpSourceConfig = serde_yaml::from_str(yaml).unwrap();
691 let mapping = &config.webhooks.unwrap().routes[0].mappings[0];
692 match &mapping.effective_from {
693 Some(EffectiveFromConfig::Explicit { value, format }) => {
694 assert_eq!(value, "{{payload.created_at}}");
695 assert_eq!(*format, TimestampFormat::Iso8601);
696 }
697 _ => panic!("Expected explicit effective_from config"),
698 }
699 }
700
701 #[test]
702 fn test_is_webhook_mode() {
703 let yaml_standard = r#"
704host: "localhost"
705port: 8080
706"#;
707 let config: HttpSourceConfig = serde_yaml::from_str(yaml_standard).unwrap();
708 assert!(!config.is_webhook_mode());
709
710 let yaml_webhook = r#"
711host: "localhost"
712port: 8080
713webhooks:
714 routes:
715 - path: "/events"
716 mappings:
717 - operation: insert
718 element_type: node
719 template:
720 id: "{{payload.id}}"
721 labels: ["Event"]
722"#;
723 let config: HttpSourceConfig = serde_yaml::from_str(yaml_webhook).unwrap();
724 assert!(config.is_webhook_mode());
725 }
726
727 #[test]
728 fn test_webhook_validation_empty_routes() {
729 let yaml = r#"
730host: "localhost"
731port: 8080
732webhooks:
733 routes: []
734"#;
735 let config: HttpSourceConfig = serde_yaml::from_str(yaml).unwrap();
736 assert!(config.validate().is_err());
737 }
738
739 #[test]
740 fn test_webhook_validation_missing_operation() {
741 let yaml = r#"
742host: "localhost"
743port: 8080
744webhooks:
745 routes:
746 - path: "/events"
747 mappings:
748 - element_type: node
749 template:
750 id: "{{payload.id}}"
751 labels: ["Event"]
752"#;
753 let config: HttpSourceConfig = serde_yaml::from_str(yaml).unwrap();
754 assert!(config.validate().is_err());
755 }
756
757 #[test]
758 fn test_webhook_validation_relation_missing_from_to() {
759 let yaml = r#"
760host: "localhost"
761port: 8080
762webhooks:
763 routes:
764 - path: "/events"
765 mappings:
766 - operation: insert
767 element_type: relation
768 template:
769 id: "{{payload.id}}"
770 labels: ["LINKS"]
771"#;
772 let config: HttpSourceConfig = serde_yaml::from_str(yaml).unwrap();
773 assert!(config.validate().is_err());
774 }
775}