openzeppelin_relayer/models/notification/
mod.rs

1//! Notification domain model and business logic.
2//!
3//! This module provides the central `Notification` type that represents notifications
4//! throughout the relayer system, including:
5//!
6//! - **Domain Model**: Core `Notification` struct with validation
7//! - **Business Logic**: Update operations and validation rules
8//! - **Error Handling**: Comprehensive validation error types
9//! - **Interoperability**: Conversions between API, config, and repository representations
10//!
11//! The notification model supports webhook-based notifications with optional message signing.
12
13mod config;
14pub use config::*;
15
16mod request;
17pub use request::*;
18
19mod response;
20pub use response::*;
21
22mod repository;
23pub use repository::NotificationRepoModel;
24
25mod webhook_notification;
26pub use webhook_notification::*;
27
28use crate::{
29    constants::{ID_REGEX, MINIMUM_SECRET_VALUE_LENGTH},
30    models::SecretString,
31};
32use serde::{Deserialize, Serialize};
33use utoipa::ToSchema;
34use validator::{Validate, ValidationError};
35
36/// Notification type enum used by both config file and API
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
38#[serde(rename_all = "lowercase")]
39pub enum NotificationType {
40    Webhook,
41}
42
43/// Notification model used by both config file and API
44#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
45pub struct Notification {
46    #[validate(
47        length(min = 1, max = 36, message = "ID must be between 1 and 36 characters"),
48        regex(
49            path = "*ID_REGEX",
50            message = "ID must contain only letters, numbers, dashes and underscores"
51        )
52    )]
53    pub id: String,
54    pub notification_type: NotificationType,
55    #[validate(url(message = "Invalid URL format"))]
56    pub url: String,
57    #[validate(custom(function = "validate_signing_key"))]
58    pub signing_key: Option<SecretString>,
59}
60
61/// Custom validator for signing key - validator handles Option automatically
62fn validate_signing_key(signing_key: &SecretString) -> Result<(), ValidationError> {
63    let is_valid = signing_key.as_str(|key_str| key_str.len() >= MINIMUM_SECRET_VALUE_LENGTH);
64    if !is_valid {
65        return Err(ValidationError::new("signing_key_too_short"));
66    }
67    Ok(())
68}
69
70impl Notification {
71    /// Creates a new notification
72    pub fn new(
73        id: String,
74        notification_type: NotificationType,
75        url: String,
76        signing_key: Option<SecretString>,
77    ) -> Self {
78        Self {
79            id,
80            notification_type,
81            url,
82            signing_key,
83        }
84    }
85
86    /// Validates the notification using the validator crate
87    pub fn validate(&self) -> Result<(), NotificationValidationError> {
88        Validate::validate(self).map_err(|validation_errors| {
89            // Convert validator errors to our custom error type
90            // Return the first error for simplicity
91            for (field, errors) in validation_errors.field_errors() {
92                if let Some(error) = errors.first() {
93                    let field_str = field.as_ref();
94                    return match (field_str, error.code.as_ref()) {
95                        ("id", "length") => NotificationValidationError::InvalidIdFormat,
96                        ("id", "regex") => NotificationValidationError::InvalidIdFormat,
97                        ("url", _) => NotificationValidationError::InvalidUrl,
98                        ("signing_key", "signing_key_too_short") => {
99                            NotificationValidationError::signing_key_too_short()
100                        }
101                        _ => NotificationValidationError::InvalidIdFormat, // fallback
102                    };
103                }
104            }
105            // Fallback error
106            NotificationValidationError::InvalidIdFormat
107        })
108    }
109
110    /// Applies an update request to create a new validated notification
111    ///
112    /// This method provides a domain-first approach where the core model handles
113    /// its own business rules and validation rather than having update logic
114    /// scattered across request models.
115    ///
116    /// # Arguments
117    /// * `request` - The update request containing partial data to apply
118    ///
119    /// # Returns
120    /// * `Ok(Notification)` - A new validated notification with updates applied
121    /// * `Err(NotificationValidationError)` - If the resulting notification would be invalid
122    pub fn apply_update(
123        &self,
124        request: &NotificationUpdateRequest,
125    ) -> Result<Self, NotificationValidationError> {
126        let mut updated = self.clone();
127
128        // Apply updates from request
129        if let Some(notification_type) = &request.r#type {
130            updated.notification_type = notification_type.clone();
131        }
132
133        if let Some(url) = &request.url {
134            updated.url = url.clone();
135        }
136
137        if let Some(signing_key) = &request.signing_key {
138            updated.signing_key = if signing_key.is_empty() {
139                // Empty string means remove the signing key
140                None
141            } else {
142                // Non-empty string means update the signing key
143                Some(SecretString::new(signing_key))
144            };
145        }
146
147        // Validate the complete updated model
148        updated.validate()?;
149
150        Ok(updated)
151    }
152}
153
154/// Common validation errors for notifications
155#[derive(Debug, thiserror::Error)]
156pub enum NotificationValidationError {
157    #[error("Notification ID cannot be empty")]
158    EmptyId,
159    #[error("Notification ID must contain only letters, numbers, dashes and underscores and must be at most 36 characters long")]
160    InvalidIdFormat,
161    #[error("Notification URL cannot be empty")]
162    EmptyUrl,
163    #[error("Invalid notification URL format")]
164    InvalidUrl,
165    #[error("Signing key must be at least {0} characters long")]
166    SigningKeyTooShort(usize),
167}
168
169impl NotificationValidationError {
170    pub fn signing_key_too_short() -> Self {
171        Self::SigningKeyTooShort(MINIMUM_SECRET_VALUE_LENGTH)
172    }
173}
174
175/// Centralized conversion from NotificationValidationError to ApiError
176impl From<NotificationValidationError> for crate::models::ApiError {
177    fn from(error: NotificationValidationError) -> Self {
178        use crate::models::ApiError;
179
180        ApiError::BadRequest(match error {
181          NotificationValidationError::EmptyId => "ID cannot be empty".to_string(),
182          NotificationValidationError::InvalidIdFormat => {
183              "ID must contain only letters, numbers, dashes and underscores and must be at most 36 characters long".to_string()
184          }
185          NotificationValidationError::EmptyUrl => "URL cannot be empty".to_string(),
186          NotificationValidationError::InvalidUrl => "Invalid URL format".to_string(),
187          NotificationValidationError::SigningKeyTooShort(min_len) => {
188              format!("Signing key must be at least {} characters long", min_len)
189          }
190      })
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn test_valid_notification() {
200        let notification = Notification::new(
201            "valid-id".to_string(),
202            NotificationType::Webhook,
203            "https://example.com/webhook".to_string(),
204            Some(SecretString::new(&"a".repeat(32))),
205        );
206
207        assert!(notification.validate().is_ok());
208    }
209
210    #[test]
211    fn test_empty_id() {
212        let notification = Notification::new(
213            "".to_string(),
214            NotificationType::Webhook,
215            "https://example.com/webhook".to_string(),
216            None,
217        );
218
219        assert!(matches!(
220            notification.validate(),
221            Err(NotificationValidationError::InvalidIdFormat)
222        ));
223    }
224
225    #[test]
226    fn test_id_too_long() {
227        let notification = Notification::new(
228            "a".repeat(37),
229            NotificationType::Webhook,
230            "https://example.com/webhook".to_string(),
231            None,
232        );
233
234        assert!(matches!(
235            notification.validate(),
236            Err(NotificationValidationError::InvalidIdFormat)
237        ));
238    }
239
240    #[test]
241    fn test_invalid_id_format() {
242        let notification = Notification::new(
243            "invalid@id".to_string(),
244            NotificationType::Webhook,
245            "https://example.com/webhook".to_string(),
246            None,
247        );
248
249        assert!(matches!(
250            notification.validate(),
251            Err(NotificationValidationError::InvalidIdFormat)
252        ));
253    }
254
255    #[test]
256    fn test_invalid_url() {
257        let notification = Notification::new(
258            "valid-id".to_string(),
259            NotificationType::Webhook,
260            "not-a-url".to_string(),
261            None,
262        );
263
264        assert!(matches!(
265            notification.validate(),
266            Err(NotificationValidationError::InvalidUrl)
267        ));
268    }
269
270    #[test]
271    fn test_signing_key_too_short() {
272        let notification = Notification::new(
273            "valid-id".to_string(),
274            NotificationType::Webhook,
275            "https://example.com/webhook".to_string(),
276            Some(SecretString::new("short")),
277        );
278
279        assert!(matches!(
280            notification.validate(),
281            Err(NotificationValidationError::SigningKeyTooShort(_))
282        ));
283    }
284
285    #[test]
286    fn test_apply_update_success() {
287        let original = Notification::new(
288            "test-id".to_string(),
289            NotificationType::Webhook,
290            "https://example.com/webhook".to_string(),
291            Some(SecretString::new(&"a".repeat(32))),
292        );
293
294        let update_request = NotificationUpdateRequest {
295            r#type: None, // Keep existing type
296            url: Some("https://updated.example.com/webhook".to_string()),
297            signing_key: Some("b".repeat(32)), // Update signing key
298        };
299
300        let result = original.apply_update(&update_request);
301        assert!(result.is_ok());
302
303        let updated = result.unwrap();
304        assert_eq!(updated.id, "test-id"); // ID should remain unchanged
305        assert_eq!(updated.notification_type, NotificationType::Webhook); // Type unchanged
306        assert_eq!(updated.url, "https://updated.example.com/webhook"); // URL updated
307        assert!(updated.signing_key.is_some()); // Signing key updated
308    }
309
310    #[test]
311    fn test_apply_update_remove_signing_key() {
312        let original = Notification::new(
313            "test-id".to_string(),
314            NotificationType::Webhook,
315            "https://example.com/webhook".to_string(),
316            Some(SecretString::new(&"a".repeat(32))),
317        );
318
319        let update_request = NotificationUpdateRequest {
320            r#type: None,
321            url: None,
322            signing_key: Some("".to_string()), // Empty string removes signing key
323        };
324
325        let result = original.apply_update(&update_request);
326        assert!(result.is_ok());
327
328        let updated = result.unwrap();
329        assert_eq!(updated.id, "test-id");
330        assert_eq!(updated.url, "https://example.com/webhook"); // URL unchanged
331        assert!(updated.signing_key.is_none()); // Signing key removed
332    }
333
334    #[test]
335    fn test_apply_update_validation_failure() {
336        let original = Notification::new(
337            "test-id".to_string(),
338            NotificationType::Webhook,
339            "https://example.com/webhook".to_string(),
340            None,
341        );
342
343        let update_request = NotificationUpdateRequest {
344            r#type: None,
345            url: Some("not-a-valid-url".to_string()), // Invalid URL
346            signing_key: None,
347        };
348
349        let result = original.apply_update(&update_request);
350        assert!(result.is_err());
351        assert!(matches!(
352            result.unwrap_err(),
353            NotificationValidationError::InvalidUrl
354        ));
355    }
356
357    #[test]
358    fn test_error_conversion_to_api_error() {
359        let error = NotificationValidationError::InvalidUrl;
360        let api_error: crate::models::ApiError = error.into();
361
362        if let crate::models::ApiError::BadRequest(msg) = api_error {
363            assert_eq!(msg, "Invalid URL format");
364        } else {
365            panic!("Expected BadRequest error");
366        }
367    }
368}