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};
24
25use drasi_lib::DurabilityConfig;
26
27/// HTTP source configuration
28///
29/// This config only contains HTTP-specific settings.
30/// Bootstrap provider configuration (database, user, password, tables, etc.)
31/// should be provided via the source's generic properties map.
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
33pub struct HttpSourceConfig {
34    /// HTTP host
35    pub host: String,
36
37    /// HTTP port
38    pub port: u16,
39
40    /// Optional endpoint path
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub endpoint: Option<String>,
43
44    /// Request timeout in milliseconds
45    #[serde(default = "default_timeout_ms")]
46    pub timeout_ms: u64,
47
48    /// Adaptive batching: maximum batch size
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub adaptive_max_batch_size: Option<usize>,
51
52    /// Adaptive batching: minimum batch size
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub adaptive_min_batch_size: Option<usize>,
55
56    /// Adaptive batching: maximum wait time in milliseconds
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub adaptive_max_wait_ms: Option<u64>,
59
60    /// Adaptive batching: minimum wait time in milliseconds
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub adaptive_min_wait_ms: Option<u64>,
63
64    /// Adaptive batching: throughput window in seconds
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub adaptive_window_secs: Option<u64>,
67
68    /// Whether adaptive batching is enabled
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub adaptive_enabled: Option<bool>,
71
72    /// Webhook configuration (enables webhook mode when present)
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub webhooks: Option<WebhookConfig>,
75
76    /// Optional WAL durability configuration.
77    ///
78    /// When present and enabled, the source persists incoming events to a
79    /// local Write-Ahead Log before acknowledging the caller, enabling
80    /// crash recovery and replay for persistent queries.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub durability: Option<DurabilityConfig>,
83}
84
85/// Returns true if webhook mode is enabled
86impl HttpSourceConfig {
87    /// Check if webhook mode is enabled
88    pub fn is_webhook_mode(&self) -> bool {
89        self.webhooks.is_some()
90    }
91}
92
93/// Webhook configuration for custom route handling
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
95pub struct WebhookConfig {
96    /// Global error behavior for unmatched/failed requests
97    #[serde(default)]
98    pub error_behavior: ErrorBehavior,
99
100    /// CORS (Cross-Origin Resource Sharing) configuration
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub cors: Option<CorsConfig>,
103
104    /// List of webhook route configurations
105    pub routes: Vec<WebhookRoute>,
106}
107
108/// CORS configuration for webhook endpoints
109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
110pub struct CorsConfig {
111    /// Whether CORS is enabled (default: true when cors section is present)
112    #[serde(default = "default_cors_enabled")]
113    pub enabled: bool,
114
115    /// Allowed origins. Use ["*"] for any origin, or specific origins like ["https://example.com"]
116    #[serde(default = "default_cors_origins")]
117    pub allow_origins: Vec<String>,
118
119    /// Allowed HTTP methods
120    #[serde(default = "default_cors_methods")]
121    pub allow_methods: Vec<String>,
122
123    /// Allowed headers. Use ["*"] for any header.
124    #[serde(default = "default_cors_headers")]
125    pub allow_headers: Vec<String>,
126
127    /// Headers to expose to the browser
128    #[serde(default, skip_serializing_if = "Vec::is_empty")]
129    pub expose_headers: Vec<String>,
130
131    /// Whether to allow credentials (cookies, authorization headers)
132    #[serde(default)]
133    pub allow_credentials: bool,
134
135    /// Max age in seconds for preflight request caching
136    #[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/// Error handling behavior for webhook requests
186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
187#[serde(rename_all = "snake_case")]
188pub enum ErrorBehavior {
189    /// Accept the request and log the issue (returns 200)
190    #[default]
191    AcceptAndLog,
192    /// Accept the request but silently discard (returns 200)
193    AcceptAndSkip,
194    /// Reject the request with an appropriate HTTP error
195    Reject,
196}
197
198/// Configuration for a single webhook route
199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
200pub struct WebhookRoute {
201    /// Route path pattern (supports `:param` for path parameters)
202    /// Example: "/github/events" or "/users/:user_id/webhooks"
203    pub path: String,
204
205    /// Allowed HTTP methods for this route
206    #[serde(default = "default_methods")]
207    pub methods: Vec<HttpMethod>,
208
209    /// Authentication configuration
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub auth: Option<AuthConfig>,
212
213    /// Error behavior override for this route
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub error_behavior: Option<ErrorBehavior>,
216
217    /// Mappings from payload to source change events
218    pub mappings: Vec<WebhookMapping>,
219}
220
221fn default_methods() -> Vec<HttpMethod> {
222    vec![HttpMethod::Post]
223}
224
225/// HTTP methods supported for webhook routes
226#[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/// Authentication configuration for a webhook route
237#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
238pub struct AuthConfig {
239    /// HMAC signature verification
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub signature: Option<SignatureConfig>,
242
243    /// Bearer token verification
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub bearer: Option<BearerConfig>,
246}
247
248/// HMAC signature verification configuration
249#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
250pub struct SignatureConfig {
251    /// Signature algorithm type
252    #[serde(rename = "type")]
253    pub algorithm: SignatureAlgorithm,
254
255    /// Environment variable containing the secret
256    pub secret_env: String,
257
258    /// Header containing the signature
259    pub header: String,
260
261    /// Prefix to strip from signature (e.g., "sha256=")
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub prefix: Option<String>,
264
265    /// Encoding of the signature (hex or base64)
266    #[serde(default)]
267    pub encoding: SignatureEncoding,
268}
269
270/// Supported HMAC signature algorithms
271#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
272#[serde(rename_all = "kebab-case")]
273pub enum SignatureAlgorithm {
274    HmacSha1,
275    HmacSha256,
276}
277
278/// Signature encoding format
279#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
280#[serde(rename_all = "lowercase")]
281pub enum SignatureEncoding {
282    #[default]
283    Hex,
284    Base64,
285}
286
287/// Bearer token verification configuration
288#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
289pub struct BearerConfig {
290    /// Environment variable containing the expected token
291    pub token_env: String,
292}
293
294// Re-export mapping types from the shared source mapping crate.
295// The `WebhookMapping` name is kept for backward compatibility with HTTP source config.
296pub 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    /// Validate the configuration and return an error if invalid.
310    ///
311    /// # Errors
312    ///
313    /// Returns an error if:
314    /// - Port is 0 (invalid port)
315    /// - Timeout is 0 (would cause immediate timeouts)
316    /// - Adaptive batching min values exceed max values
317    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        // Validate adaptive batching settings
333        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        // Validate webhook configuration if present
353        if let Some(ref webhooks) = self.webhooks {
354            webhooks.validate()?;
355        }
356
357        Ok(())
358    }
359}
360
361impl WebhookConfig {
362    /// Validate webhook configuration
363    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    /// Validate webhook route configuration
382    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); // default
424        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        // Test valid port
510        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        // Test minimum port
518        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}