Skip to main content

allsource_core/domain/value_objects/
schema_subject.rs

1use crate::error::Result;
2use serde::{Deserialize, Serialize};
3use std::fmt;
4
5/// Value Object: SchemaSubject
6///
7/// Represents the subject/topic that a schema applies to.
8/// Subjects typically correspond to event types (e.g., "user.created", "order.placed").
9///
10/// Domain Rules:
11/// - Cannot be empty
12/// - Must be between 1 and 256 characters
13/// - Must be lowercase with dots, underscores, or hyphens
14/// - Case-sensitive
15/// - Immutable once created
16/// - Must be unique within a schema registry
17///
18/// This is a Value Object:
19/// - Defined by its value, not identity
20/// - Immutable
21/// - Self-validating
22/// - Compared by value equality
23#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
24pub struct SchemaSubject(String);
25
26impl SchemaSubject {
27    /// Create a new SchemaSubject with validation
28    ///
29    /// # Errors
30    /// Returns error if:
31    /// - Subject is empty
32    /// - Subject is longer than 256 characters
33    /// - Subject contains invalid characters
34    ///
35    /// # Examples
36    /// ```
37    /// use allsource_core::domain::value_objects::SchemaSubject;
38    ///
39    /// let subject = SchemaSubject::new("user.created".to_string()).unwrap();
40    /// assert_eq!(subject.as_str(), "user.created");
41    /// ```
42    pub fn new(value: String) -> Result<Self> {
43        Self::validate(&value)?;
44        Ok(Self(value))
45    }
46
47    /// Create SchemaSubject without validation (for internal use, e.g., from trusted storage)
48    ///
49    /// # Safety
50    /// This bypasses validation. Only use when loading from trusted sources
51    /// where validation has already occurred.
52    pub(crate) fn new_unchecked(value: String) -> Self {
53        Self(value)
54    }
55
56    /// Get the string value
57    pub fn as_str(&self) -> &str {
58        &self.0
59    }
60
61    /// Get the inner String (consumes self)
62    pub fn into_inner(self) -> String {
63        self.0
64    }
65
66    /// Get the namespace (everything before the first dot)
67    ///
68    /// # Examples
69    /// ```
70    /// use allsource_core::domain::value_objects::SchemaSubject;
71    ///
72    /// let subject = SchemaSubject::new("user.created".to_string()).unwrap();
73    /// assert_eq!(subject.namespace(), Some("user"));
74    ///
75    /// let subject = SchemaSubject::new("simple".to_string()).unwrap();
76    /// assert_eq!(subject.namespace(), None);
77    /// ```
78    pub fn namespace(&self) -> Option<&str> {
79        self.0.split('.').next().filter(|_| self.0.contains('.'))
80    }
81
82    /// Get the action/event name (everything after the last dot)
83    ///
84    /// # Examples
85    /// ```
86    /// use allsource_core::domain::value_objects::SchemaSubject;
87    ///
88    /// let subject = SchemaSubject::new("user.created".to_string()).unwrap();
89    /// assert_eq!(subject.action(), Some("created"));
90    ///
91    /// let subject = SchemaSubject::new("user.profile.updated".to_string()).unwrap();
92    /// assert_eq!(subject.action(), Some("updated"));
93    /// ```
94    pub fn action(&self) -> Option<&str> {
95        self.0.rsplit('.').next().filter(|_| self.0.contains('.'))
96    }
97
98    /// Check if this subject is in a specific namespace
99    ///
100    /// # Examples
101    /// ```
102    /// use allsource_core::domain::value_objects::SchemaSubject;
103    ///
104    /// let subject = SchemaSubject::new("user.created".to_string()).unwrap();
105    /// assert!(subject.is_in_namespace("user"));
106    /// assert!(!subject.is_in_namespace("order"));
107    /// ```
108    pub fn is_in_namespace(&self, namespace: &str) -> bool {
109        self.namespace() == Some(namespace)
110    }
111
112    /// Check if this subject starts with a prefix
113    pub fn starts_with(&self, prefix: &str) -> bool {
114        self.0.starts_with(prefix)
115    }
116
117    /// Check if this subject matches an event type pattern
118    ///
119    /// Supports wildcards: `*` matches any single segment, `**` matches any number of segments
120    ///
121    /// # Examples
122    /// ```
123    /// use allsource_core::domain::value_objects::SchemaSubject;
124    ///
125    /// let subject = SchemaSubject::new("user.created".to_string()).unwrap();
126    /// assert!(subject.matches_pattern("user.created"));
127    /// assert!(subject.matches_pattern("user.*"));
128    /// assert!(subject.matches_pattern("**"));
129    /// ```
130    pub fn matches_pattern(&self, pattern: &str) -> bool {
131        if pattern == "**" {
132            return true;
133        }
134
135        let subject_parts: Vec<&str> = self.0.split('.').collect();
136        let pattern_parts: Vec<&str> = pattern.split('.').collect();
137
138        if pattern.contains("**") {
139            // Simple implementation: ** at the end matches any trailing segments
140            let prefix: Vec<&str> = pattern_parts
141                .iter()
142                .take_while(|&&p| p != "**")
143                .copied()
144                .collect();
145
146            if subject_parts.len() < prefix.len() {
147                return false;
148            }
149
150            for (s, p) in subject_parts.iter().zip(prefix.iter()) {
151                if *p != "*" && *s != *p {
152                    return false;
153                }
154            }
155            true
156        } else {
157            // Exact segment matching with * wildcards
158            if subject_parts.len() != pattern_parts.len() {
159                return false;
160            }
161
162            for (s, p) in subject_parts.iter().zip(pattern_parts.iter()) {
163                if *p != "*" && *s != *p {
164                    return false;
165                }
166            }
167            true
168        }
169    }
170
171    /// Validate a schema subject string
172    fn validate(value: &str) -> Result<()> {
173        // Rule: Cannot be empty
174        if value.is_empty() {
175            return Err(crate::error::AllSourceError::InvalidInput(
176                "Schema subject cannot be empty".to_string(),
177            ));
178        }
179
180        // Rule: Maximum length 256 characters
181        if value.len() > 256 {
182            return Err(crate::error::AllSourceError::InvalidInput(format!(
183                "Schema subject cannot exceed 256 characters, got {}",
184                value.len()
185            )));
186        }
187
188        // Rule: Must be lowercase with dots, underscores, or hyphens
189        if !value
190            .chars()
191            .all(|c| c.is_lowercase() || c.is_numeric() || c == '.' || c == '_' || c == '-')
192        {
193            return Err(crate::error::AllSourceError::InvalidInput(format!(
194                "Schema subject '{}' must be lowercase with dots, underscores, or hyphens",
195                value
196            )));
197        }
198
199        // Rule: Cannot start or end with special characters
200        if value.starts_with('.')
201            || value.starts_with('-')
202            || value.starts_with('_')
203            || value.ends_with('.')
204            || value.ends_with('-')
205            || value.ends_with('_')
206        {
207            return Err(crate::error::AllSourceError::InvalidInput(format!(
208                "Schema subject '{}' cannot start or end with special characters",
209                value
210            )));
211        }
212
213        // Rule: Cannot have consecutive dots
214        if value.contains("..") {
215            return Err(crate::error::AllSourceError::InvalidInput(format!(
216                "Schema subject '{}' cannot have consecutive dots",
217                value
218            )));
219        }
220
221        Ok(())
222    }
223}
224
225impl fmt::Display for SchemaSubject {
226    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227        write!(f, "{}", self.0)
228    }
229}
230
231impl TryFrom<&str> for SchemaSubject {
232    type Error = crate::error::AllSourceError;
233
234    fn try_from(value: &str) -> Result<Self> {
235        SchemaSubject::new(value.to_string())
236    }
237}
238
239impl TryFrom<String> for SchemaSubject {
240    type Error = crate::error::AllSourceError;
241
242    fn try_from(value: String) -> Result<Self> {
243        SchemaSubject::new(value)
244    }
245}
246
247impl AsRef<str> for SchemaSubject {
248    fn as_ref(&self) -> &str {
249        &self.0
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn test_create_valid_subjects() {
259        // Simple dot-separated
260        let subject = SchemaSubject::new("user.created".to_string());
261        assert!(subject.is_ok());
262        assert_eq!(subject.unwrap().as_str(), "user.created");
263
264        // Three parts
265        let subject = SchemaSubject::new("user.profile.updated".to_string());
266        assert!(subject.is_ok());
267
268        // With underscore
269        let subject = SchemaSubject::new("order_item.created".to_string());
270        assert!(subject.is_ok());
271
272        // With hyphen
273        let subject = SchemaSubject::new("payment-processed".to_string());
274        assert!(subject.is_ok());
275
276        // With numbers
277        let subject = SchemaSubject::new("event.v2.updated".to_string());
278        assert!(subject.is_ok());
279
280        // Single word
281        let subject = SchemaSubject::new("created".to_string());
282        assert!(subject.is_ok());
283    }
284
285    #[test]
286    fn test_reject_empty_subject() {
287        let result = SchemaSubject::new("".to_string());
288        assert!(result.is_err());
289
290        if let Err(e) = result {
291            assert!(e.to_string().contains("cannot be empty"));
292        }
293    }
294
295    #[test]
296    fn test_reject_too_long_subject() {
297        let long_subject = "a".repeat(257);
298        let result = SchemaSubject::new(long_subject);
299        assert!(result.is_err());
300
301        if let Err(e) = result {
302            assert!(e.to_string().contains("cannot exceed 256 characters"));
303        }
304    }
305
306    #[test]
307    fn test_accept_max_length_subject() {
308        let max_subject = "a".repeat(256);
309        let result = SchemaSubject::new(max_subject);
310        assert!(result.is_ok());
311    }
312
313    #[test]
314    fn test_reject_uppercase() {
315        let result = SchemaSubject::new("User.Created".to_string());
316        assert!(result.is_err());
317
318        let result = SchemaSubject::new("user.CREATED".to_string());
319        assert!(result.is_err());
320    }
321
322    #[test]
323    fn test_reject_invalid_characters() {
324        // Space
325        let result = SchemaSubject::new("user created".to_string());
326        assert!(result.is_err());
327
328        // Colon
329        let result = SchemaSubject::new("user:created".to_string());
330        assert!(result.is_err());
331
332        // Special characters
333        let result = SchemaSubject::new("user@created".to_string());
334        assert!(result.is_err());
335    }
336
337    #[test]
338    fn test_reject_starting_with_special_char() {
339        let result = SchemaSubject::new(".user.created".to_string());
340        assert!(result.is_err());
341
342        let result = SchemaSubject::new("-user.created".to_string());
343        assert!(result.is_err());
344
345        let result = SchemaSubject::new("_user.created".to_string());
346        assert!(result.is_err());
347    }
348
349    #[test]
350    fn test_reject_ending_with_special_char() {
351        let result = SchemaSubject::new("user.created.".to_string());
352        assert!(result.is_err());
353
354        let result = SchemaSubject::new("user.created-".to_string());
355        assert!(result.is_err());
356
357        let result = SchemaSubject::new("user.created_".to_string());
358        assert!(result.is_err());
359    }
360
361    #[test]
362    fn test_reject_consecutive_dots() {
363        let result = SchemaSubject::new("user..created".to_string());
364        assert!(result.is_err());
365
366        if let Err(e) = result {
367            assert!(e.to_string().contains("consecutive dots"));
368        }
369    }
370
371    #[test]
372    fn test_namespace_extraction() {
373        let subject = SchemaSubject::new("user.created".to_string()).unwrap();
374        assert_eq!(subject.namespace(), Some("user"));
375
376        let subject = SchemaSubject::new("user.profile.updated".to_string()).unwrap();
377        assert_eq!(subject.namespace(), Some("user"));
378
379        // No dot
380        let subject = SchemaSubject::new("created".to_string()).unwrap();
381        assert_eq!(subject.namespace(), None);
382    }
383
384    #[test]
385    fn test_action_extraction() {
386        let subject = SchemaSubject::new("user.created".to_string()).unwrap();
387        assert_eq!(subject.action(), Some("created"));
388
389        let subject = SchemaSubject::new("user.profile.updated".to_string()).unwrap();
390        assert_eq!(subject.action(), Some("updated"));
391
392        // No dot
393        let subject = SchemaSubject::new("created".to_string()).unwrap();
394        assert_eq!(subject.action(), None);
395    }
396
397    #[test]
398    fn test_is_in_namespace() {
399        let subject = SchemaSubject::new("user.created".to_string()).unwrap();
400        assert!(subject.is_in_namespace("user"));
401        assert!(!subject.is_in_namespace("order"));
402    }
403
404    #[test]
405    fn test_starts_with() {
406        let subject = SchemaSubject::new("user.created".to_string()).unwrap();
407        assert!(subject.starts_with("user"));
408        assert!(subject.starts_with("user."));
409        assert!(!subject.starts_with("order"));
410    }
411
412    #[test]
413    fn test_matches_pattern_exact() {
414        let subject = SchemaSubject::new("user.created".to_string()).unwrap();
415        assert!(subject.matches_pattern("user.created"));
416        assert!(!subject.matches_pattern("user.updated"));
417    }
418
419    #[test]
420    fn test_matches_pattern_wildcard() {
421        let subject = SchemaSubject::new("user.created".to_string()).unwrap();
422        assert!(subject.matches_pattern("user.*"));
423        assert!(subject.matches_pattern("*.created"));
424        assert!(subject.matches_pattern("*.*"));
425        assert!(!subject.matches_pattern("order.*"));
426    }
427
428    #[test]
429    fn test_matches_pattern_double_wildcard() {
430        let subject = SchemaSubject::new("user.profile.updated".to_string()).unwrap();
431        assert!(subject.matches_pattern("**"));
432        assert!(subject.matches_pattern("user.**"));
433        assert!(subject.matches_pattern("user.profile.**"));
434    }
435
436    #[test]
437    fn test_display_trait() {
438        let subject = SchemaSubject::new("user.created".to_string()).unwrap();
439        assert_eq!(format!("{}", subject), "user.created");
440    }
441
442    #[test]
443    fn test_try_from_str() {
444        let subject: Result<SchemaSubject> = "user.created".try_into();
445        assert!(subject.is_ok());
446        assert_eq!(subject.unwrap().as_str(), "user.created");
447
448        let invalid: Result<SchemaSubject> = "".try_into();
449        assert!(invalid.is_err());
450    }
451
452    #[test]
453    fn test_try_from_string() {
454        let subject: Result<SchemaSubject> = "order.placed".to_string().try_into();
455        assert!(subject.is_ok());
456
457        let invalid: Result<SchemaSubject> = String::new().try_into();
458        assert!(invalid.is_err());
459    }
460
461    #[test]
462    fn test_into_inner() {
463        let subject = SchemaSubject::new("test.subject".to_string()).unwrap();
464        let inner = subject.into_inner();
465        assert_eq!(inner, "test.subject");
466    }
467
468    #[test]
469    fn test_equality() {
470        let subject1 = SchemaSubject::new("user.created".to_string()).unwrap();
471        let subject2 = SchemaSubject::new("user.created".to_string()).unwrap();
472        let subject3 = SchemaSubject::new("order.placed".to_string()).unwrap();
473
474        assert_eq!(subject1, subject2);
475        assert_ne!(subject1, subject3);
476    }
477
478    #[test]
479    fn test_cloning() {
480        let subject1 = SchemaSubject::new("test.subject".to_string()).unwrap();
481        let subject2 = subject1.clone();
482        assert_eq!(subject1, subject2);
483    }
484
485    #[test]
486    fn test_hash_consistency() {
487        use std::collections::HashSet;
488
489        let subject1 = SchemaSubject::new("user.created".to_string()).unwrap();
490        let subject2 = SchemaSubject::new("user.created".to_string()).unwrap();
491
492        let mut set = HashSet::new();
493        set.insert(subject1);
494
495        assert!(set.contains(&subject2));
496    }
497
498    #[test]
499    fn test_serde_serialization() {
500        let subject = SchemaSubject::new("user.created".to_string()).unwrap();
501
502        // Serialize
503        let json = serde_json::to_string(&subject).unwrap();
504        assert_eq!(json, "\"user.created\"");
505
506        // Deserialize
507        let deserialized: SchemaSubject = serde_json::from_str(&json).unwrap();
508        assert_eq!(deserialized, subject);
509    }
510
511    #[test]
512    fn test_as_ref() {
513        let subject = SchemaSubject::new("test.subject".to_string()).unwrap();
514        let str_ref: &str = subject.as_ref();
515        assert_eq!(str_ref, "test.subject");
516    }
517
518    #[test]
519    fn test_new_unchecked() {
520        // Should create without validation (for internal use)
521        let subject = SchemaSubject::new_unchecked("INVALID Subject!".to_string());
522        assert_eq!(subject.as_str(), "INVALID Subject!");
523    }
524}