openzeppelin_relayer/models/notification/
config.rs1use crate::{
12 config::ConfigFileError,
13 models::{
14 notification::Notification, NotificationType, NotificationValidationError, PlainOrEnvValue,
15 SecretString,
16 },
17};
18use serde::{Deserialize, Serialize};
19use std::collections::HashSet;
20
21#[derive(Debug, Serialize, Deserialize, Clone)]
23#[serde(deny_unknown_fields)]
24pub struct NotificationConfig {
25 pub id: String,
26 pub r#type: NotificationType,
27 pub url: String,
28 pub signing_key: Option<PlainOrEnvValue>,
29}
30
31impl TryFrom<NotificationConfig> for Notification {
32 type Error = ConfigFileError;
33
34 fn try_from(config: NotificationConfig) -> Result<Self, Self::Error> {
35 let signing_key = config.get_signing_key()?;
36
37 let notification = Notification::new(config.id, config.r#type, config.url, signing_key);
39
40 notification.validate().map_err(|e| match e {
42 NotificationValidationError::EmptyId => {
43 ConfigFileError::MissingField("notification id".into())
44 }
45 NotificationValidationError::InvalidIdFormat => {
46 ConfigFileError::InvalidFormat("Invalid notification ID format".into())
47 }
48 NotificationValidationError::EmptyUrl => {
49 ConfigFileError::MissingField("Webhook URL is required".into())
50 }
51 NotificationValidationError::InvalidUrl => {
52 ConfigFileError::InvalidFormat("Invalid Webhook URL".into())
53 }
54 NotificationValidationError::SigningKeyTooShort(min_len) => {
55 ConfigFileError::InvalidFormat(format!(
56 "Signing key must be at least {min_len} characters long"
57 ))
58 }
59 })?;
60
61 Ok(notification)
62 }
63}
64
65impl NotificationConfig {
66 pub fn validate(&self) -> Result<(), ConfigFileError> {
68 let _notification = Notification::try_from(self.clone())?;
69 Ok(())
70 }
71
72 pub fn to_core_notification(&self) -> Result<Notification, ConfigFileError> {
74 Notification::try_from(self.clone())
75 }
76
77 pub fn get_signing_key(&self) -> Result<Option<SecretString>, ConfigFileError> {
79 match &self.signing_key {
80 Some(signing_key) => match signing_key {
81 PlainOrEnvValue::Env { value } => {
82 if value.is_empty() {
83 return Err(ConfigFileError::MissingField(
84 "Signing key environment variable name cannot be empty".into(),
85 ));
86 }
87
88 match std::env::var(value) {
89 Ok(key_value) => {
90 let secret = SecretString::new(&key_value);
91 Ok(Some(secret))
92 }
93 Err(e) => Err(ConfigFileError::MissingEnvVar(format!(
94 "Environment variable '{value}' not found: {e}"
95 ))),
96 }
97 }
98 PlainOrEnvValue::Plain { value } => {
99 let is_empty = value.as_str(|s| s.is_empty());
100 if is_empty {
101 return Err(ConfigFileError::InvalidFormat(
102 "Signing key value cannot be empty".into(),
103 ));
104 }
105 Ok(Some(value.clone()))
106 }
107 },
108 None => Ok(None),
109 }
110 }
111}
112
113#[derive(Debug, Serialize, Deserialize, Clone)]
115#[serde(deny_unknown_fields)]
116pub struct NotificationConfigs {
117 pub notifications: Vec<NotificationConfig>,
118}
119
120impl NotificationConfigs {
121 pub fn new(notifications: Vec<NotificationConfig>) -> Self {
123 Self { notifications }
124 }
125
126 pub fn validate(&self) -> Result<(), ConfigFileError> {
128 if self.notifications.is_empty() {
129 return Ok(());
130 }
131
132 let mut ids = HashSet::new();
133 for notification in &self.notifications {
134 notification.validate()?;
136
137 if !ids.insert(notification.id.clone()) {
139 return Err(ConfigFileError::DuplicateId(notification.id.clone()));
140 }
141 }
142 Ok(())
143 }
144
145 pub fn to_core_notifications(&self) -> Result<Vec<Notification>, ConfigFileError> {
147 self.notifications
148 .iter()
149 .map(|config| Notification::try_from(config.clone()))
150 .collect()
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 #[test]
159 fn test_valid_notification_config_conversion() {
160 let config = NotificationConfig {
161 id: "test-webhook".to_string(),
162 r#type: NotificationType::Webhook,
163 url: "https://example.com/webhook".to_string(),
164 signing_key: Some(PlainOrEnvValue::Plain {
165 value: SecretString::new(&"a".repeat(32)),
166 }),
167 };
168
169 let result = Notification::try_from(config);
170 assert!(result.is_ok());
171
172 let notification = result.unwrap();
173 assert_eq!(notification.id, "test-webhook");
174 assert_eq!(notification.notification_type, NotificationType::Webhook);
175 assert_eq!(notification.url, "https://example.com/webhook");
176 assert!(notification.signing_key.is_some());
177 }
178
179 #[test]
180 fn test_invalid_notification_config_conversion() {
181 let config = NotificationConfig {
182 id: "invalid@id".to_string(), r#type: NotificationType::Webhook,
184 url: "https://example.com/webhook".to_string(),
185 signing_key: None,
186 };
187
188 let result = Notification::try_from(config);
189 assert!(result.is_err());
190
191 if let Err(ConfigFileError::InvalidFormat(msg)) = result {
192 assert!(msg.contains("Invalid notification ID format"));
193 } else {
194 panic!("Expected InvalidFormat error");
195 }
196 }
197
198 #[test]
199 fn test_to_core_notification() {
200 let config = NotificationConfig {
201 id: "test-webhook".to_string(),
202 r#type: NotificationType::Webhook,
203 url: "https://example.com/webhook".to_string(),
204 signing_key: Some(PlainOrEnvValue::Plain {
205 value: SecretString::new(&"a".repeat(32)),
206 }),
207 };
208
209 let core = config.to_core_notification().unwrap();
210 assert_eq!(core.id, "test-webhook");
211 assert_eq!(core.notification_type, NotificationType::Webhook);
212 assert_eq!(core.url, "https://example.com/webhook");
213 assert!(core.signing_key.is_some());
214 }
215
216 #[test]
217 fn test_notification_configs_validation() {
218 let configs = NotificationConfigs::new(vec![
219 NotificationConfig {
220 id: "webhook1".to_string(),
221 r#type: NotificationType::Webhook,
222 url: "https://example.com/webhook1".to_string(),
223 signing_key: None,
224 },
225 NotificationConfig {
226 id: "webhook2".to_string(),
227 r#type: NotificationType::Webhook,
228 url: "https://example.com/webhook2".to_string(),
229 signing_key: None,
230 },
231 ]);
232
233 assert!(configs.validate().is_ok());
234 }
235
236 #[test]
237 fn test_duplicate_ids() {
238 let configs = NotificationConfigs::new(vec![
239 NotificationConfig {
240 id: "webhook1".to_string(),
241 r#type: NotificationType::Webhook,
242 url: "https://example.com/webhook1".to_string(),
243 signing_key: None,
244 },
245 NotificationConfig {
246 id: "webhook1".to_string(), r#type: NotificationType::Webhook,
248 url: "https://example.com/webhook2".to_string(),
249 signing_key: None,
250 },
251 ]);
252
253 assert!(matches!(
254 configs.validate(),
255 Err(ConfigFileError::DuplicateId(_))
256 ));
257 }
258
259 #[test]
260 fn test_config_with_short_signing_key() {
261 let config = NotificationConfig {
262 id: "test-webhook".to_string(),
263 r#type: NotificationType::Webhook,
264 url: "https://example.com/webhook".to_string(),
265 signing_key: Some(PlainOrEnvValue::Plain {
266 value: SecretString::new("short"), }),
268 };
269
270 let result = Notification::try_from(config);
271 assert!(result.is_err());
272
273 if let Err(ConfigFileError::InvalidFormat(msg)) = result {
274 assert!(msg.contains("Signing key must be at least"));
275 } else {
276 panic!("Expected InvalidFormat error for short key");
277 }
278 }
279
280 #[test]
282 fn test_valid_webhook_notification_json() {
283 use serde_json::json;
284
285 let config = json!({
286 "id": "notification-test",
287 "type": "webhook",
288 "url": "https://api.example.com/notifications"
289 });
290
291 let notification: NotificationConfig = serde_json::from_value(config).unwrap();
292 assert!(notification.validate().is_ok());
293 assert_eq!(notification.id, "notification-test");
294 assert_eq!(notification.r#type, NotificationType::Webhook);
295 }
296
297 #[test]
298 fn test_invalid_webhook_url_json() {
299 use serde_json::json;
300
301 let config = json!({
302 "id": "notification-test",
303 "type": "webhook",
304 "url": "invalid-url"
305 });
306
307 let notification: NotificationConfig = serde_json::from_value(config).unwrap();
308 assert!(notification.validate().is_err());
309 }
310
311 #[test]
312 fn test_webhook_notification_with_signing_key_json() {
313 use serde_json::json;
314
315 let config = json!({
316 "id": "notification-test",
317 "type": "webhook",
318 "url": "https://api.example.com/notifications",
319 "signing_key": {
320 "type": "plain",
321 "value": "a".repeat(32)
322 }
323 });
324
325 let notification: NotificationConfig = serde_json::from_value(config).unwrap();
326 assert!(notification.validate().is_ok());
327 assert!(notification.get_signing_key().unwrap().is_some());
328 }
329
330 #[test]
331 fn test_webhook_notification_with_env_signing_key_json() {
332 use serde_json::json;
333 use std::sync::Mutex;
334
335 static ENV_MUTEX: Mutex<()> = Mutex::new(());
336 let _lock = ENV_MUTEX.lock().unwrap();
337
338 std::env::set_var("TEST_SIGNING_KEY", "a".repeat(32));
340
341 let config = json!({
342 "id": "notification-test",
343 "type": "webhook",
344 "url": "https://api.example.com/notifications",
345 "signing_key": {
346 "type": "env",
347 "value": "TEST_SIGNING_KEY"
348 }
349 });
350
351 let notification: NotificationConfig = serde_json::from_value(config).unwrap();
352 assert!(notification.validate().is_ok());
353 assert!(notification.get_signing_key().unwrap().is_some());
354
355 std::env::remove_var("TEST_SIGNING_KEY");
357 }
358}