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 '{value}' must be lowercase with dots, underscores, or hyphens"
195            )));
196        }
197
198        // Rule: Cannot start or end with special characters
199        if value.starts_with('.')
200            || value.starts_with('-')
201            || value.starts_with('_')
202            || value.ends_with('.')
203            || value.ends_with('-')
204            || value.ends_with('_')
205        {
206            return Err(crate::error::AllSourceError::InvalidInput(format!(
207                "Schema subject '{value}' cannot start or end with special characters"
208            )));
209        }
210
211        // Rule: Cannot have consecutive dots
212        if value.contains("..") {
213            return Err(crate::error::AllSourceError::InvalidInput(format!(
214                "Schema subject '{value}' cannot have consecutive dots"
215            )));
216        }
217
218        Ok(())
219    }
220}
221
222impl fmt::Display for SchemaSubject {
223    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224        write!(f, "{}", self.0)
225    }
226}
227
228impl TryFrom<&str> for SchemaSubject {
229    type Error = crate::error::AllSourceError;
230
231    fn try_from(value: &str) -> Result<Self> {
232        SchemaSubject::new(value.to_string())
233    }
234}
235
236impl TryFrom<String> for SchemaSubject {
237    type Error = crate::error::AllSourceError;
238
239    fn try_from(value: String) -> Result<Self> {
240        SchemaSubject::new(value)
241    }
242}
243
244impl AsRef<str> for SchemaSubject {
245    fn as_ref(&self) -> &str {
246        &self.0
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn test_create_valid_subjects() {
256        // Simple dot-separated
257        let subject = SchemaSubject::new("user.created".to_string());
258        assert!(subject.is_ok());
259        assert_eq!(subject.unwrap().as_str(), "user.created");
260
261        // Three parts
262        let subject = SchemaSubject::new("user.profile.updated".to_string());
263        assert!(subject.is_ok());
264
265        // With underscore
266        let subject = SchemaSubject::new("order_item.created".to_string());
267        assert!(subject.is_ok());
268
269        // With hyphen
270        let subject = SchemaSubject::new("payment-processed".to_string());
271        assert!(subject.is_ok());
272
273        // With numbers
274        let subject = SchemaSubject::new("event.v2.updated".to_string());
275        assert!(subject.is_ok());
276
277        // Single word
278        let subject = SchemaSubject::new("created".to_string());
279        assert!(subject.is_ok());
280    }
281
282    #[test]
283    fn test_reject_empty_subject() {
284        let result = SchemaSubject::new(String::new());
285        assert!(result.is_err());
286
287        if let Err(e) = result {
288            assert!(e.to_string().contains("cannot be empty"));
289        }
290    }
291
292    #[test]
293    fn test_reject_too_long_subject() {
294        let long_subject = "a".repeat(257);
295        let result = SchemaSubject::new(long_subject);
296        assert!(result.is_err());
297
298        if let Err(e) = result {
299            assert!(e.to_string().contains("cannot exceed 256 characters"));
300        }
301    }
302
303    #[test]
304    fn test_accept_max_length_subject() {
305        let max_subject = "a".repeat(256);
306        let result = SchemaSubject::new(max_subject);
307        assert!(result.is_ok());
308    }
309
310    #[test]
311    fn test_reject_uppercase() {
312        let result = SchemaSubject::new("User.Created".to_string());
313        assert!(result.is_err());
314
315        let result = SchemaSubject::new("user.CREATED".to_string());
316        assert!(result.is_err());
317    }
318
319    #[test]
320    fn test_reject_invalid_characters() {
321        // Space
322        let result = SchemaSubject::new("user created".to_string());
323        assert!(result.is_err());
324
325        // Colon
326        let result = SchemaSubject::new("user:created".to_string());
327        assert!(result.is_err());
328
329        // Special characters
330        let result = SchemaSubject::new("user@created".to_string());
331        assert!(result.is_err());
332    }
333
334    #[test]
335    fn test_reject_starting_with_special_char() {
336        let result = SchemaSubject::new(".user.created".to_string());
337        assert!(result.is_err());
338
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
346    #[test]
347    fn test_reject_ending_with_special_char() {
348        let result = SchemaSubject::new("user.created.".to_string());
349        assert!(result.is_err());
350
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
358    #[test]
359    fn test_reject_consecutive_dots() {
360        let result = SchemaSubject::new("user..created".to_string());
361        assert!(result.is_err());
362
363        if let Err(e) = result {
364            assert!(e.to_string().contains("consecutive dots"));
365        }
366    }
367
368    #[test]
369    fn test_namespace_extraction() {
370        let subject = SchemaSubject::new("user.created".to_string()).unwrap();
371        assert_eq!(subject.namespace(), Some("user"));
372
373        let subject = SchemaSubject::new("user.profile.updated".to_string()).unwrap();
374        assert_eq!(subject.namespace(), Some("user"));
375
376        // No dot
377        let subject = SchemaSubject::new("created".to_string()).unwrap();
378        assert_eq!(subject.namespace(), None);
379    }
380
381    #[test]
382    fn test_action_extraction() {
383        let subject = SchemaSubject::new("user.created".to_string()).unwrap();
384        assert_eq!(subject.action(), Some("created"));
385
386        let subject = SchemaSubject::new("user.profile.updated".to_string()).unwrap();
387        assert_eq!(subject.action(), Some("updated"));
388
389        // No dot
390        let subject = SchemaSubject::new("created".to_string()).unwrap();
391        assert_eq!(subject.action(), None);
392    }
393
394    #[test]
395    fn test_is_in_namespace() {
396        let subject = SchemaSubject::new("user.created".to_string()).unwrap();
397        assert!(subject.is_in_namespace("user"));
398        assert!(!subject.is_in_namespace("order"));
399    }
400
401    #[test]
402    fn test_starts_with() {
403        let subject = SchemaSubject::new("user.created".to_string()).unwrap();
404        assert!(subject.starts_with("user"));
405        assert!(subject.starts_with("user."));
406        assert!(!subject.starts_with("order"));
407    }
408
409    #[test]
410    fn test_matches_pattern_exact() {
411        let subject = SchemaSubject::new("user.created".to_string()).unwrap();
412        assert!(subject.matches_pattern("user.created"));
413        assert!(!subject.matches_pattern("user.updated"));
414    }
415
416    #[test]
417    fn test_matches_pattern_wildcard() {
418        let subject = SchemaSubject::new("user.created".to_string()).unwrap();
419        assert!(subject.matches_pattern("user.*"));
420        assert!(subject.matches_pattern("*.created"));
421        assert!(subject.matches_pattern("*.*"));
422        assert!(!subject.matches_pattern("order.*"));
423    }
424
425    #[test]
426    fn test_matches_pattern_double_wildcard() {
427        let subject = SchemaSubject::new("user.profile.updated".to_string()).unwrap();
428        assert!(subject.matches_pattern("**"));
429        assert!(subject.matches_pattern("user.**"));
430        assert!(subject.matches_pattern("user.profile.**"));
431    }
432
433    #[test]
434    fn test_display_trait() {
435        let subject = SchemaSubject::new("user.created".to_string()).unwrap();
436        assert_eq!(format!("{subject}"), "user.created");
437    }
438
439    #[test]
440    fn test_try_from_str() {
441        let subject: Result<SchemaSubject> = "user.created".try_into();
442        assert!(subject.is_ok());
443        assert_eq!(subject.unwrap().as_str(), "user.created");
444
445        let invalid: Result<SchemaSubject> = "".try_into();
446        assert!(invalid.is_err());
447    }
448
449    #[test]
450    fn test_try_from_string() {
451        let subject: Result<SchemaSubject> = "order.placed".to_string().try_into();
452        assert!(subject.is_ok());
453
454        let invalid: Result<SchemaSubject> = String::new().try_into();
455        assert!(invalid.is_err());
456    }
457
458    #[test]
459    fn test_into_inner() {
460        let subject = SchemaSubject::new("test.subject".to_string()).unwrap();
461        let inner = subject.into_inner();
462        assert_eq!(inner, "test.subject");
463    }
464
465    #[test]
466    fn test_equality() {
467        let subject1 = SchemaSubject::new("user.created".to_string()).unwrap();
468        let subject2 = SchemaSubject::new("user.created".to_string()).unwrap();
469        let subject3 = SchemaSubject::new("order.placed".to_string()).unwrap();
470
471        assert_eq!(subject1, subject2);
472        assert_ne!(subject1, subject3);
473    }
474
475    #[test]
476    fn test_cloning() {
477        let subject1 = SchemaSubject::new("test.subject".to_string()).unwrap();
478        let subject2 = subject1.clone();
479        assert_eq!(subject1, subject2);
480    }
481
482    #[test]
483    fn test_hash_consistency() {
484        use std::collections::HashSet;
485
486        let subject1 = SchemaSubject::new("user.created".to_string()).unwrap();
487        let subject2 = SchemaSubject::new("user.created".to_string()).unwrap();
488
489        let mut set = HashSet::new();
490        set.insert(subject1);
491
492        assert!(set.contains(&subject2));
493    }
494
495    #[test]
496    fn test_serde_serialization() {
497        let subject = SchemaSubject::new("user.created".to_string()).unwrap();
498
499        // Serialize
500        let json = serde_json::to_string(&subject).unwrap();
501        assert_eq!(json, "\"user.created\"");
502
503        // Deserialize
504        let deserialized: SchemaSubject = serde_json::from_str(&json).unwrap();
505        assert_eq!(deserialized, subject);
506    }
507
508    #[test]
509    fn test_as_ref() {
510        let subject = SchemaSubject::new("test.subject".to_string()).unwrap();
511        let str_ref: &str = subject.as_ref();
512        assert_eq!(str_ref, "test.subject");
513    }
514
515    #[test]
516    fn test_new_unchecked() {
517        // Should create without validation (for internal use)
518        let subject = SchemaSubject::new_unchecked("INVALID Subject!".to_string());
519        assert_eq!(subject.as_str(), "INVALID Subject!");
520    }
521}