openzeppelin_relayer/models/notification/
mod.rs1mod 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
38#[serde(rename_all = "lowercase")]
39pub enum NotificationType {
40 Webhook,
41}
42
43#[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
61fn 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 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 pub fn validate(&self) -> Result<(), NotificationValidationError> {
88 Validate::validate(self).map_err(|validation_errors| {
89 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, };
103 }
104 }
105 NotificationValidationError::InvalidIdFormat
107 })
108 }
109
110 pub fn apply_update(
123 &self,
124 request: &NotificationUpdateRequest,
125 ) -> Result<Self, NotificationValidationError> {
126 let mut updated = self.clone();
127
128 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 None
141 } else {
142 Some(SecretString::new(signing_key))
144 };
145 }
146
147 updated.validate()?;
149
150 Ok(updated)
151 }
152}
153
154#[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
175impl 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, url: Some("https://updated.example.com/webhook".to_string()),
297 signing_key: Some("b".repeat(32)), };
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"); assert_eq!(updated.notification_type, NotificationType::Webhook); assert_eq!(updated.url, "https://updated.example.com/webhook"); assert!(updated.signing_key.is_some()); }
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()), };
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"); assert!(updated.signing_key.is_none()); }
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()), 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}