Skip to main content

fraiseql_webhooks/
config.rs

1//! Webhook configuration structures.
2
3use std::collections::HashMap;
4
5use serde::Deserialize;
6
7use crate::WebhookError;
8
9/// Webhook endpoint configuration
10#[derive(Debug, Clone, Deserialize)]
11pub struct WebhookConfig {
12    /// Provider type (stripe, github, etc.) - inferred from key if not specified
13    pub provider: Option<String>,
14
15    /// Endpoint path (default: /webhooks/{name})
16    pub path: Option<String>,
17
18    /// Secret environment variable name
19    pub secret_env: String,
20
21    /// Signature scheme (for custom providers)
22    pub signature_scheme: Option<String>,
23
24    /// Custom signature header (for custom providers)
25    pub signature_header: Option<String>,
26
27    /// Timestamp header (for custom providers)
28    pub timestamp_header: Option<String>,
29
30    /// Timestamp tolerance in seconds
31    #[serde(default = "default_timestamp_tolerance")]
32    pub timestamp_tolerance: u64,
33
34    /// Enable idempotency checking
35    #[serde(default = "default_idempotent")]
36    pub idempotent: bool,
37
38    /// Event mappings
39    #[serde(default)]
40    pub events: HashMap<String, WebhookEventConfig>,
41}
42
43impl WebhookConfig {
44    /// Validate that `secret_env` is a legal POSIX environment variable name.
45    ///
46    /// Accepts `[A-Za-z_][A-Za-z0-9_]*`. Rejects `=`, NUL bytes, and empty strings
47    /// which are OS-undefined or could cause environment injection.
48    ///
49    /// # Errors
50    ///
51    /// Returns `WebhookError::Configuration` if `secret_env` is invalid.
52    ///
53    /// # Panics
54    ///
55    /// Cannot panic in practice — the `expect` is guarded by a preceding
56    /// emptiness check that returns `Err` before the call site is reached.
57    pub fn validate_secret_env(&self) -> Result<(), WebhookError> {
58        let name = &self.secret_env;
59        if name.is_empty() {
60            return Err(WebhookError::Configuration("secret_env cannot be empty".to_string()));
61        }
62        let mut chars = name.chars();
63        let first = chars.next().expect("non-empty; checked above");
64        if !first.is_ascii_alphabetic() && first != '_' {
65            return Err(WebhookError::Configuration(format!(
66                "secret_env '{name}' must start with a letter or underscore"
67            )));
68        }
69        for ch in chars {
70            if !ch.is_ascii_alphanumeric() && ch != '_' {
71                return Err(WebhookError::Configuration(format!(
72                    "secret_env '{name}' contains invalid character '{ch}' (only [A-Za-z0-9_] allowed)"
73                )));
74            }
75        }
76        Ok(())
77    }
78}
79
80fn default_timestamp_tolerance() -> u64 {
81    300
82}
83
84fn default_idempotent() -> bool {
85    true
86}
87
88/// Event handler configuration
89#[derive(Debug, Clone, Deserialize)]
90pub struct WebhookEventConfig {
91    /// Database function to call
92    pub function: String,
93
94    /// Field mapping from webhook payload to function parameters
95    #[serde(default)]
96    pub mapping: HashMap<String, String>,
97
98    /// Condition expression (optional)
99    pub condition: Option<String>,
100}
101
102#[allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn test_default_values() {
109        let json = r#"{
110            "secret_env": "WEBHOOK_SECRET",
111            "events": {}
112        }"#;
113
114        let config: WebhookConfig = serde_json::from_str(json).unwrap();
115        assert_eq!(config.timestamp_tolerance, 300);
116        assert!(config.idempotent);
117    }
118
119    #[test]
120    fn test_custom_values() {
121        let json = r#"{
122            "provider": "stripe",
123            "secret_env": "STRIPE_SECRET",
124            "timestamp_tolerance": 600,
125            "idempotent": false,
126            "events": {
127                "payment_intent.succeeded": {
128                    "function": "handle_payment",
129                    "mapping": {
130                        "payment_id": "data.object.id"
131                    }
132                }
133            }
134        }"#;
135
136        let config: WebhookConfig = serde_json::from_str(json).unwrap();
137        assert_eq!(config.provider, Some("stripe".to_string()));
138        assert_eq!(config.timestamp_tolerance, 600);
139        assert!(!config.idempotent);
140        assert_eq!(config.events.len(), 1);
141    }
142}