Skip to main content

drasi_source_http/
config.rs

1// Copyright 2025 The Drasi Authors.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Configuration for the HTTP source.
16//!
17//! The HTTP source receives data changes via HTTP endpoints.
18//! It supports two mutually exclusive modes:
19//!
20//! - **Standard Mode**: Uses the built-in `HttpSourceChange` format
21//! - **Webhook Mode**: Custom routes with configurable payload mappings
22
23use serde::{Deserialize, Serialize};
24use std::collections::HashMap;
25
26use drasi_lib::DurabilityConfig;
27
28/// HTTP source configuration
29///
30/// This config only contains HTTP-specific settings.
31/// Bootstrap provider configuration (database, user, password, tables, etc.)
32/// should be provided via the source's generic properties map.
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34pub struct HttpSourceConfig {
35    /// HTTP host
36    pub host: String,
37
38    /// HTTP port
39    pub port: u16,
40
41    /// Optional endpoint path
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub endpoint: Option<String>,
44
45    /// Request timeout in milliseconds
46    #[serde(default = "default_timeout_ms")]
47    pub timeout_ms: u64,
48
49    /// Adaptive batching: maximum batch size
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub adaptive_max_batch_size: Option<usize>,
52
53    /// Adaptive batching: minimum batch size
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub adaptive_min_batch_size: Option<usize>,
56
57    /// Adaptive batching: maximum wait time in milliseconds
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub adaptive_max_wait_ms: Option<u64>,
60
61    /// Adaptive batching: minimum wait time in milliseconds
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub adaptive_min_wait_ms: Option<u64>,
64
65    /// Adaptive batching: throughput window in seconds
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub adaptive_window_secs: Option<u64>,
68
69    /// Whether adaptive batching is enabled
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub adaptive_enabled: Option<bool>,
72
73    /// Webhook configuration (enables webhook mode when present)
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub webhooks: Option<WebhookConfig>,
76
77    /// Optional WAL durability configuration.
78    ///
79    /// When present and enabled, the source persists incoming events to a
80    /// local Write-Ahead Log before acknowledging the caller, enabling
81    /// crash recovery and replay for persistent queries.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub durability: Option<DurabilityConfig>,
84}
85
86/// Returns true if webhook mode is enabled
87impl HttpSourceConfig {
88    /// Check if webhook mode is enabled
89    pub fn is_webhook_mode(&self) -> bool {
90        self.webhooks.is_some()
91    }
92}
93
94/// Webhook configuration for custom route handling
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
96pub struct WebhookConfig {
97    /// Global error behavior for unmatched/failed requests
98    #[serde(default)]
99    pub error_behavior: ErrorBehavior,
100
101    /// CORS (Cross-Origin Resource Sharing) configuration
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub cors: Option<CorsConfig>,
104
105    /// List of webhook route configurations
106    pub routes: Vec<WebhookRoute>,
107}
108
109/// CORS configuration for webhook endpoints
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
111pub struct CorsConfig {
112    /// Whether CORS is enabled (default: true when cors section is present)
113    #[serde(default = "default_cors_enabled")]
114    pub enabled: bool,
115
116    /// Allowed origins. Use ["*"] for any origin, or specific origins like ["https://example.com"]
117    #[serde(default = "default_cors_origins")]
118    pub allow_origins: Vec<String>,
119
120    /// Allowed HTTP methods
121    #[serde(default = "default_cors_methods")]
122    pub allow_methods: Vec<String>,
123
124    /// Allowed headers. Use ["*"] for any header.
125    #[serde(default = "default_cors_headers")]
126    pub allow_headers: Vec<String>,
127
128    /// Headers to expose to the browser
129    #[serde(default, skip_serializing_if = "Vec::is_empty")]
130    pub expose_headers: Vec<String>,
131
132    /// Whether to allow credentials (cookies, authorization headers)
133    #[serde(default)]
134    pub allow_credentials: bool,
135
136    /// Max age in seconds for preflight request caching
137    #[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/// Error handling behavior for webhook requests
187#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
188#[serde(rename_all = "snake_case")]
189pub enum ErrorBehavior {
190    /// Accept the request and log the issue (returns 200)
191    #[default]
192    AcceptAndLog,
193    /// Accept the request but silently discard (returns 200)
194    AcceptAndSkip,
195    /// Reject the request with an appropriate HTTP error
196    Reject,
197}
198
199/// Configuration for a single webhook route
200#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
201pub struct WebhookRoute {
202    /// Route path pattern (supports `:param` for path parameters)
203    /// Example: "/github/events" or "/users/:user_id/webhooks"
204    pub path: String,
205
206    /// Allowed HTTP methods for this route
207    #[serde(default = "default_methods")]
208    pub methods: Vec<HttpMethod>,
209
210    /// Authentication configuration
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub auth: Option<AuthConfig>,
213
214    /// Error behavior override for this route
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub error_behavior: Option<ErrorBehavior>,
217
218    /// Mappings from payload to source change events
219    pub mappings: Vec<WebhookMapping>,
220}
221
222fn default_methods() -> Vec<HttpMethod> {
223    vec![HttpMethod::Post]
224}
225
226/// HTTP methods supported for webhook routes
227#[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/// Authentication configuration for a webhook route
238#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
239pub struct AuthConfig {
240    /// HMAC signature verification
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub signature: Option<SignatureConfig>,
243
244    /// Bearer token verification
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub bearer: Option<BearerConfig>,
247}
248
249/// HMAC signature verification configuration
250#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
251pub struct SignatureConfig {
252    /// Signature algorithm type
253    #[serde(rename = "type")]
254    pub algorithm: SignatureAlgorithm,
255
256    /// Environment variable containing the secret
257    pub secret_env: String,
258
259    /// Header containing the signature
260    pub header: String,
261
262    /// Prefix to strip from signature (e.g., "sha256=")
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub prefix: Option<String>,
265
266    /// Encoding of the signature (hex or base64)
267    #[serde(default)]
268    pub encoding: SignatureEncoding,
269}
270
271/// Supported HMAC signature algorithms
272#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
273#[serde(rename_all = "kebab-case")]
274pub enum SignatureAlgorithm {
275    HmacSha1,
276    HmacSha256,
277}
278
279/// Signature encoding format
280#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
281#[serde(rename_all = "lowercase")]
282pub enum SignatureEncoding {
283    #[default]
284    Hex,
285    Base64,
286}
287
288/// Bearer token verification configuration
289#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
290pub struct BearerConfig {
291    /// Environment variable containing the expected token
292    pub token_env: String,
293}
294
295/// Mapping configuration from webhook payload to source change event
296#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
297pub struct WebhookMapping {
298    /// Optional condition for when this mapping applies
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub when: Option<MappingCondition>,
301
302    /// Static operation type
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub operation: Option<OperationType>,
305
306    /// Path to extract operation from payload
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub operation_from: Option<String>,
309
310    /// Mapping from payload values to operation types
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub operation_map: Option<HashMap<String, OperationType>>,
313
314    /// Element type to create
315    pub element_type: ElementType,
316
317    /// Timestamp configuration for effective_from
318    #[serde(default, skip_serializing_if = "Option::is_none")]
319    pub effective_from: Option<EffectiveFromConfig>,
320
321    /// Template for element creation
322    pub template: ElementTemplate,
323}
324
325/// Condition for when a mapping applies
326#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
327pub struct MappingCondition {
328    /// Header to check
329    #[serde(default, skip_serializing_if = "Option::is_none")]
330    pub header: Option<String>,
331
332    /// Payload field path to check
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub field: Option<String>,
335
336    /// Value must equal this
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub equals: Option<String>,
339
340    /// Value must contain this
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub contains: Option<String>,
343
344    /// Value must match this regex
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub regex: Option<String>,
347}
348
349/// Operation type for source changes
350#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
351#[serde(rename_all = "lowercase")]
352pub enum OperationType {
353    Insert,
354    Update,
355    Delete,
356}
357
358/// Element type for source changes
359#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
360#[serde(rename_all = "lowercase")]
361pub enum ElementType {
362    Node,
363    Relation,
364}
365
366/// Configuration for effective_from timestamp
367#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
368#[serde(untagged)]
369pub enum EffectiveFromConfig {
370    /// Simple template string (auto-detect format)
371    Simple(String),
372    /// Explicit configuration with format
373    Explicit {
374        /// Template for the timestamp value
375        value: String,
376        /// Format of the timestamp
377        format: TimestampFormat,
378    },
379}
380
381/// Timestamp format for effective_from
382#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
383#[serde(rename_all = "snake_case")]
384pub enum TimestampFormat {
385    /// ISO 8601 datetime string
386    Iso8601,
387    /// Unix timestamp in seconds
388    UnixSeconds,
389    /// Unix timestamp in milliseconds
390    UnixMillis,
391    /// Unix timestamp in nanoseconds
392    UnixNanos,
393}
394
395/// Template for element creation
396#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
397pub struct ElementTemplate {
398    /// Template for element ID
399    pub id: String,
400
401    /// Templates for element labels
402    pub labels: Vec<String>,
403
404    /// Templates for element properties (can be individual templates or a single object template)
405    #[serde(default, skip_serializing_if = "Option::is_none")]
406    pub properties: Option<serde_json::Value>,
407
408    /// Template for relation source node ID (relations only)
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub from: Option<String>,
411
412    /// Template for relation target node ID (relations only)
413    #[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    /// Validate the configuration and return an error if invalid.
423    ///
424    /// # Errors
425    ///
426    /// Returns an error if:
427    /// - Port is 0 (invalid port)
428    /// - Timeout is 0 (would cause immediate timeouts)
429    /// - Adaptive batching min values exceed max values
430    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        // Validate adaptive batching settings
446        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        // Validate webhook configuration if present
466        if let Some(ref webhooks) = self.webhooks {
467            webhooks.validate()?;
468        }
469
470        Ok(())
471    }
472}
473
474impl WebhookConfig {
475    /// Validate webhook configuration
476    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    /// Validate webhook route configuration
495    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    /// Validate webhook mapping configuration
524    pub fn validate(&self) -> anyhow::Result<()> {
525        // Must have either static operation or dynamic operation_from
526        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 using operation_from, should have operation_map
533        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        // Validate template
540        self.template.validate(&self.element_type)?;
541
542        Ok(())
543    }
544}
545
546impl ElementTemplate {
547    /// Validate element template configuration
548    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        // Relations require from and to
558        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); // default
590        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        // Test valid port
676        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        // Test minimum port
684        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}