openzeppelin_relayer/models/notification/
request.rs

1//! API request models and validation for notification endpoints.
2//!
3//! This module handles incoming HTTP requests for notification operations, providing:
4//!
5//! - **Request Models**: Structures for creating and updating notifications via API
6//! - **Input Validation**: Sanitization and validation of user-provided data
7//! - **Domain Conversion**: Transformation from API requests to domain objects
8//!
9//! Serves as the entry point for notification data from external clients, ensuring
10//! all input is properly validated before reaching the core business logic.
11
12use crate::models::{notification::Notification, ApiError, NotificationType, SecretString};
13use serde::{Deserialize, Serialize};
14use utoipa::ToSchema;
15
16/// Request structure for creating a new notification
17#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
18#[serde(deny_unknown_fields)]
19pub struct NotificationCreateRequest {
20    #[schema(nullable = false)]
21    pub id: Option<String>,
22    #[schema(nullable = false)]
23    pub r#type: Option<NotificationType>,
24    pub url: String,
25    /// Optional signing key for securing webhook notifications
26    #[schema(nullable = false)]
27    pub signing_key: Option<String>,
28}
29
30/// Request structure for updating an existing notification
31#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
32#[serde(deny_unknown_fields)]
33pub struct NotificationUpdateRequest {
34    #[schema(nullable = false)]
35    pub r#type: Option<NotificationType>,
36    #[schema(nullable = false)]
37    pub url: Option<String>,
38    /// Optional signing key for securing webhook notifications.
39    /// - None: don't change the existing signing key
40    /// - Some(""): remove the signing key
41    /// - Some("key"): set the signing key to the provided value
42    pub signing_key: Option<String>,
43}
44
45impl TryFrom<NotificationCreateRequest> for Notification {
46    type Error = ApiError;
47
48    fn try_from(request: NotificationCreateRequest) -> Result<Self, Self::Error> {
49        let signing_key = request.signing_key.map(|s| SecretString::new(&s));
50        let id = request
51            .id
52            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
53        let notification_type = request.r#type.unwrap_or(NotificationType::Webhook);
54
55        let notification = Notification::new(id, notification_type, request.url, signing_key);
56
57        // Validate using core validation logic
58        notification.validate().map_err(ApiError::from)?;
59
60        Ok(notification)
61    }
62}
63
64#[cfg(test)]
65mod tests {
66    use crate::models::NotificationRepoModel;
67
68    use super::*;
69
70    #[test]
71    fn test_valid_create_request_conversion() {
72        let request = NotificationCreateRequest {
73            id: Some("test-notification".to_string()),
74            r#type: Some(NotificationType::Webhook),
75            url: "https://example.com/webhook".to_string(),
76            signing_key: Some("a".repeat(32)), // Minimum length
77        };
78
79        let result = Notification::try_from(request);
80        assert!(result.is_ok());
81
82        let notification = result.unwrap();
83        assert_eq!(notification.id, "test-notification");
84        assert_eq!(notification.notification_type, NotificationType::Webhook);
85        assert_eq!(notification.url, "https://example.com/webhook");
86        assert!(notification.signing_key.is_some());
87    }
88
89    #[test]
90    fn test_invalid_create_request_conversion() {
91        let request = NotificationCreateRequest {
92            id: Some("invalid@id".to_string()), // Invalid characters
93            r#type: Some(NotificationType::Webhook),
94            url: "https://example.com/webhook".to_string(),
95            signing_key: None,
96        };
97
98        let result = Notification::try_from(request);
99        assert!(result.is_err());
100
101        if let Err(ApiError::BadRequest(msg)) = result {
102            assert!(msg.contains("ID must contain only letters, numbers, dashes and underscores"));
103        } else {
104            panic!("Expected BadRequest error");
105        }
106    }
107
108    #[test]
109    fn test_signing_key_too_short() {
110        let request = NotificationCreateRequest {
111            id: Some("test-notification".to_string()),
112            r#type: Some(NotificationType::Webhook),
113            url: "https://example.com/webhook".to_string(),
114            signing_key: Some("short".to_string()), // Too short
115        };
116
117        let result = Notification::try_from(request);
118        assert!(result.is_err());
119
120        if let Err(ApiError::BadRequest(msg)) = result {
121            assert!(msg.contains("Signing key must be at least"));
122        } else {
123            panic!("Expected BadRequest error");
124        }
125    }
126
127    #[test]
128    fn test_invalid_url() {
129        let request = NotificationCreateRequest {
130            id: Some("test-notification".to_string()),
131            r#type: Some(NotificationType::Webhook),
132            url: "not-a-url".to_string(),
133            signing_key: None,
134        };
135
136        let result = Notification::try_from(request);
137        assert!(result.is_err());
138
139        if let Err(ApiError::BadRequest(msg)) = result {
140            assert!(msg.contains("Invalid URL format"));
141        } else {
142            panic!("Expected BadRequest error");
143        }
144    }
145
146    #[test]
147    fn test_update_request_validation_domain_first() {
148        // Create existing core notification
149        let existing_core = Notification::new(
150            "test-id".to_string(),
151            NotificationType::Webhook,
152            "https://example.com/webhook".to_string(),
153            Some(SecretString::new("existing-key")),
154        );
155
156        let update_request = NotificationUpdateRequest {
157            r#type: None,
158            url: Some("https://new-example.com/webhook".to_string()),
159            signing_key: Some("a".repeat(32)), // Valid length
160        };
161
162        let result = existing_core.apply_update(&update_request);
163        assert!(result.is_ok());
164
165        let updated = result.unwrap();
166        assert_eq!(updated.id, "test-id"); // ID should remain unchanged
167        assert_eq!(updated.url, "https://new-example.com/webhook"); // URL should be updated
168        assert!(updated.signing_key.is_some()); // Signing key should be updated
169    }
170
171    #[test]
172    fn test_update_request_invalid_url_domain_first() {
173        // Create existing core notification
174        let existing_core = Notification::new(
175            "test-id".to_string(),
176            NotificationType::Webhook,
177            "https://example.com/webhook".to_string(),
178            None,
179        );
180
181        let update_request = NotificationUpdateRequest {
182            r#type: None,
183            url: Some("not-a-url".to_string()), // Invalid URL
184            signing_key: None,
185        };
186
187        let result = existing_core.apply_update(&update_request);
188        assert!(result.is_err());
189
190        // Test the From conversion to ApiError
191        let api_error: ApiError = result.unwrap_err().into();
192        if let ApiError::BadRequest(msg) = api_error {
193            assert!(msg.contains("Invalid URL format"));
194        } else {
195            panic!("Expected BadRequest error");
196        }
197    }
198
199    #[test]
200    fn test_notification_to_repo_model() {
201        let notification = Notification::new(
202            "test-id".to_string(),
203            NotificationType::Webhook,
204            "https://example.com/webhook".to_string(),
205            Some(SecretString::new("test-key")),
206        );
207
208        let repo_model = NotificationRepoModel::from(notification);
209        assert_eq!(repo_model.id, "test-id");
210        assert_eq!(repo_model.notification_type, NotificationType::Webhook);
211        assert_eq!(repo_model.url, "https://example.com/webhook");
212        assert!(repo_model.signing_key.is_some());
213    }
214}