scim_server/resource/value_objects/
meta.rs

1//! Meta value object for SCIM resource metadata.
2//!
3//! This module provides a type-safe wrapper around SCIM meta attributes with built-in validation.
4//! Meta attributes contain common metadata for all SCIM resources including timestamps, location,
5//! and version information.
6
7use crate::error::{ValidationError, ValidationResult};
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use std::fmt;
11
12/// A validated SCIM meta attribute.
13///
14/// Meta represents the common metadata for SCIM resources as defined in RFC 7643.
15/// It enforces validation rules at construction time, ensuring that only valid meta
16/// attributes can exist in the system.
17///
18/// ## Validation Rules
19///
20/// - Resource type must not be empty
21/// - Created timestamp must be valid ISO 8601 format
22/// - Last modified timestamp must be valid ISO 8601 format
23/// - Last modified must not be before created timestamp
24/// - Location URI, if provided, must be valid format
25/// - Version, if provided, must follow ETag format
26///
27/// ## Examples
28///
29/// ```rust
30/// use scim_server::resource::value_objects::Meta;
31/// use chrono::Utc;
32///
33/// fn main() -> Result<(), Box<dyn std::error::Error>> {
34///     let now = Utc::now();
35///     let meta = Meta::new(
36///         "User".to_string(),
37///         now,
38///         now,
39///         Some("https://example.com/Users/123".to_string()),
40///         Some("123-456".to_string())
41///     )?;
42///     println!("Resource type: {}", meta.resource_type());
43///
44///     Ok(())
45/// }
46/// ```
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct Meta {
49    #[serde(rename = "resourceType")]
50    pub resource_type: String,
51    pub created: DateTime<Utc>,
52    #[serde(rename = "lastModified")]
53    pub last_modified: DateTime<Utc>,
54    pub location: Option<String>,
55    pub version: Option<String>,
56}
57
58impl Meta {
59    /// Create a new Meta with full attributes.
60    ///
61    /// This is the primary constructor that enforces all validation rules.
62    /// Use this method when creating Meta instances from untrusted input.
63    ///
64    /// # Arguments
65    ///
66    /// * `resource_type` - The SCIM resource type (e.g., "User", "Group")
67    /// * `created` - The resource creation timestamp
68    /// * `last_modified` - The resource last modification timestamp
69    /// * `location` - Optional location URI for the resource
70    /// * `version` - Optional version identifier (raw format)
71    ///
72    /// # Returns
73    ///
74    /// * `Ok(Meta)` - If all values are valid
75    /// * `Err(ValidationError)` - If any value violates validation rules
76    pub fn new(
77        resource_type: String,
78        created: DateTime<Utc>,
79        last_modified: DateTime<Utc>,
80        location: Option<String>,
81        version: Option<String>,
82    ) -> ValidationResult<Self> {
83        Self::validate_resource_type(&resource_type)?;
84        Self::validate_timestamps(created, last_modified)?;
85        if let Some(ref location_val) = location {
86            Self::validate_location(location_val)?;
87        }
88        if let Some(ref version_val) = version {
89            Self::validate_version(version_val)?;
90        }
91
92        Ok(Self {
93            resource_type,
94            created,
95            last_modified,
96            location,
97            version,
98        })
99    }
100
101    /// Create a simple Meta with just resource type and timestamps.
102    ///
103    /// Convenience constructor for creating meta attributes without optional fields.
104    ///
105    /// # Arguments
106    ///
107    /// * `resource_type` - The SCIM resource type
108    /// * `created` - The resource creation timestamp
109    /// * `last_modified` - The resource last modification timestamp
110    ///
111    /// # Returns
112    ///
113    /// * `Ok(Meta)` - If the values are valid
114    /// * `Err(ValidationError)` - If any value violates validation rules
115    pub fn new_simple(
116        resource_type: String,
117        created: DateTime<Utc>,
118        last_modified: DateTime<Utc>,
119    ) -> ValidationResult<Self> {
120        Self::new(resource_type, created, last_modified, None, None)
121    }
122
123    /// Create a Meta for a new resource with current timestamp.
124    ///
125    /// Convenience constructor for creating meta attributes for new resources.
126    /// Sets both created and last_modified to the current time.
127    ///
128    /// # Arguments
129    ///
130    /// * `resource_type` - The SCIM resource type
131    ///
132    /// # Returns
133    ///
134    /// * `Ok(Meta)` - If the resource type is valid
135    /// * `Err(ValidationError)` - If the resource type violates validation rules
136    pub fn new_for_creation(resource_type: String) -> ValidationResult<Self> {
137        let now = Utc::now();
138        Self::new_simple(resource_type, now, now)
139    }
140
141    /// Get the resource type.
142    pub fn resource_type(&self) -> &str {
143        &self.resource_type
144    }
145
146    /// Get the created timestamp.
147    pub fn created(&self) -> DateTime<Utc> {
148        self.created
149    }
150
151    /// Get the last modified timestamp.
152    pub fn last_modified(&self) -> DateTime<Utc> {
153        self.last_modified
154    }
155
156    /// Get the location URI.
157    pub fn location(&self) -> Option<&str> {
158        self.location.as_deref()
159    }
160
161    /// Get the version identifier.
162    pub fn version(&self) -> Option<&str> {
163        self.version.as_deref()
164    }
165
166    /// Create a new Meta with updated last modified timestamp.
167    ///
168    /// This method creates a new Meta instance with the last_modified timestamp
169    /// updated to the current time, preserving all other attributes.
170    pub fn with_updated_timestamp(&self) -> Self {
171        Self {
172            resource_type: self.resource_type.clone(),
173            created: self.created,
174            last_modified: Utc::now(),
175            location: self.location.clone(),
176            version: self.version.clone(),
177        }
178    }
179
180    /// Create a new Meta with a specific location.
181    ///
182    /// This method creates a new Meta instance with the location set to the
183    /// provided value, preserving all other attributes.
184    pub fn with_location(mut self, location: String) -> ValidationResult<Self> {
185        Self::validate_location(&location)?;
186        self.location = Some(location);
187        Ok(self)
188    }
189
190    /// Create a new Meta with a specific version.
191    ///
192    /// This method creates a new Meta instance with the version set to the
193    /// provided value, preserving all other attributes.
194    pub fn with_version(mut self, version: String) -> ValidationResult<Self> {
195        Self::validate_version(&version)?;
196        self.version = Some(version);
197        Ok(self)
198    }
199
200    /// Generate a location URI for the resource.
201    ///
202    /// Creates a standard SCIM location URI based on the base URL, resource type,
203    /// and resource ID.
204    pub fn generate_location(base_url: &str, resource_type: &str, resource_id: &str) -> String {
205        format!(
206            "{}/{}s/{}",
207            base_url.trim_end_matches('/'),
208            resource_type,
209            resource_id
210        )
211    }
212
213    /// Validate the resource type value.
214    fn validate_resource_type(resource_type: &str) -> ValidationResult<()> {
215        if resource_type.is_empty() {
216            return Err(ValidationError::MissingResourceType);
217        }
218
219        // Resource type should be a valid identifier
220        if !resource_type
221            .chars()
222            .all(|c| c.is_alphanumeric() || c == '_')
223        {
224            return Err(ValidationError::InvalidResourceType {
225                resource_type: resource_type.to_string(),
226            });
227        }
228
229        Ok(())
230    }
231
232    /// Validate the timestamp values.
233    fn validate_timestamps(
234        created: DateTime<Utc>,
235        last_modified: DateTime<Utc>,
236    ) -> ValidationResult<()> {
237        if last_modified < created {
238            return Err(ValidationError::Custom {
239                message: "Last modified timestamp cannot be before created timestamp".to_string(),
240            });
241        }
242
243        Ok(())
244    }
245
246    /// Validate the location URI value.
247    fn validate_location(location: &str) -> ValidationResult<()> {
248        if location.is_empty() {
249            return Err(ValidationError::InvalidLocationUri);
250        }
251
252        // Basic URI validation - should start with http:// or https://
253        if !location.starts_with("http://") && !location.starts_with("https://") {
254            return Err(ValidationError::InvalidLocationUri);
255        }
256
257        Ok(())
258    }
259
260    /// Validate the version identifier value.
261    fn validate_version(version: &str) -> ValidationResult<()> {
262        if version.is_empty() {
263            return Err(ValidationError::InvalidVersionFormat);
264        }
265
266        // Version should be alphanumeric with common separators
267        // Allow letters, numbers, hyphens, underscores, dots, and equals (for base64)
268        if !version
269            .chars()
270            .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '+' | '/' | '='))
271        {
272            return Err(ValidationError::InvalidVersionFormat);
273        }
274
275        Ok(())
276    }
277}
278
279impl fmt::Display for Meta {
280    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
281        write!(
282            f,
283            "Meta(resourceType={}, created={}, lastModified={})",
284            self.resource_type,
285            self.created.to_rfc3339(),
286            self.last_modified.to_rfc3339()
287        )
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use chrono::{TimeZone, Utc};
295    use serde_json;
296
297    #[test]
298    fn test_valid_meta_full() {
299        let created = Utc.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap();
300        let modified = Utc.with_ymd_and_hms(2023, 1, 2, 12, 0, 0).unwrap();
301
302        let meta = Meta::new(
303            "User".to_string(),
304            created,
305            modified,
306            Some("https://example.com/Users/123".to_string()),
307            Some("123-456".to_string()),
308        );
309        assert!(meta.is_ok());
310
311        let meta = meta.unwrap();
312        assert_eq!(meta.resource_type(), "User");
313        assert_eq!(meta.created(), created);
314        assert_eq!(meta.last_modified(), modified);
315        assert_eq!(meta.location(), Some("https://example.com/Users/123"));
316        assert_eq!(meta.version(), Some("123-456"));
317    }
318
319    #[test]
320    fn test_valid_meta_simple() {
321        let created = Utc.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap();
322        let modified = Utc.with_ymd_and_hms(2023, 1, 1, 12, 30, 0).unwrap();
323
324        let meta = Meta::new_simple("Group".to_string(), created, modified);
325        assert!(meta.is_ok());
326
327        let meta = meta.unwrap();
328        assert_eq!(meta.resource_type(), "Group");
329        assert_eq!(meta.created(), created);
330        assert_eq!(meta.last_modified(), modified);
331        assert_eq!(meta.location(), None);
332        assert_eq!(meta.version(), None);
333    }
334
335    #[test]
336    fn test_new_for_creation() {
337        let meta = Meta::new_for_creation("User".to_string());
338        assert!(meta.is_ok());
339
340        let meta = meta.unwrap();
341        assert_eq!(meta.resource_type(), "User");
342        assert_eq!(meta.created(), meta.last_modified());
343    }
344
345    #[test]
346    fn test_empty_resource_type() {
347        let now = Utc::now();
348        let result = Meta::new_simple("".to_string(), now, now);
349        assert!(result.is_err());
350
351        match result.unwrap_err() {
352            ValidationError::MissingResourceType => {}
353            other => panic!("Expected MissingResourceType error, got: {:?}", other),
354        }
355    }
356
357    #[test]
358    fn test_invalid_resource_type() {
359        let now = Utc::now();
360        let result = Meta::new_simple("Invalid-Type!".to_string(), now, now);
361        assert!(result.is_err());
362
363        match result.unwrap_err() {
364            ValidationError::InvalidResourceType { resource_type } => {
365                assert_eq!(resource_type, "Invalid-Type!");
366            }
367            other => panic!("Expected InvalidResourceType error, got: {:?}", other),
368        }
369    }
370
371    #[test]
372    fn test_invalid_timestamps() {
373        let created = Utc.with_ymd_and_hms(2023, 1, 2, 12, 0, 0).unwrap();
374        let modified = Utc.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap(); // Before created
375
376        let result = Meta::new_simple("User".to_string(), created, modified);
377        assert!(result.is_err());
378
379        match result.unwrap_err() {
380            ValidationError::Custom { message } => {
381                assert!(message.contains("Last modified timestamp cannot be before created"));
382            }
383            other => panic!("Expected Custom error, got: {:?}", other),
384        }
385    }
386
387    #[test]
388    fn test_invalid_location() {
389        let now = Utc::now();
390
391        // Empty location
392        let result = Meta::new("User".to_string(), now, now, Some("".to_string()), None);
393        assert!(result.is_err());
394
395        // Invalid URI format
396        let result = Meta::new(
397            "User".to_string(),
398            now,
399            now,
400            Some("not-a-uri".to_string()),
401            None,
402        );
403        assert!(result.is_err());
404    }
405
406    #[test]
407    fn test_invalid_version() {
408        let now = Utc::now();
409
410        // Empty version
411        let result = Meta::new("User".to_string(), now, now, None, Some("".to_string()));
412        assert!(result.is_err());
413
414        // Invalid ETag format
415        let result = Meta::new(
416            "User".to_string(),
417            now,
418            now,
419            None,
420            Some("invalid@version!".to_string()),
421        );
422        assert!(result.is_err());
423
424        match result.unwrap_err() {
425            ValidationError::InvalidVersionFormat => {}
426            other => panic!("Expected InvalidVersionFormat error, got: {:?}", other),
427        }
428    }
429
430    #[test]
431    fn test_with_updated_timestamp() {
432        let created = Utc.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap();
433        let meta = Meta::new_simple("User".to_string(), created, created).unwrap();
434
435        std::thread::sleep(std::time::Duration::from_millis(10));
436        let updated_meta = meta.with_updated_timestamp();
437
438        assert_eq!(updated_meta.created(), created);
439        assert!(updated_meta.last_modified() > created);
440        assert_eq!(updated_meta.resource_type(), "User");
441    }
442
443    #[test]
444    fn test_with_location() {
445        let now = Utc::now();
446        let meta = Meta::new_simple("User".to_string(), now, now).unwrap();
447
448        let meta_with_location = meta
449            .clone()
450            .with_location("https://example.com/Users/123".to_string());
451        assert!(meta_with_location.is_ok());
452
453        let meta_with_location = meta_with_location.unwrap();
454        assert_eq!(
455            meta_with_location.location(),
456            Some("https://example.com/Users/123")
457        );
458
459        // Test invalid location
460        let invalid_result = meta.with_location("invalid-uri".to_string());
461        assert!(invalid_result.is_err());
462    }
463
464    #[test]
465    fn test_with_version() {
466        let now = Utc::now();
467        let meta = Meta::new_simple("User".to_string(), now, now).unwrap();
468
469        let meta_with_version = meta.clone().with_version("123-456".to_string());
470        assert!(meta_with_version.is_ok());
471
472        let meta_with_version = meta_with_version.unwrap();
473        assert_eq!(meta_with_version.version(), Some("123-456"));
474
475        // Test invalid version
476        let invalid_meta = meta.with_version("invalid@version".to_string());
477        assert!(invalid_meta.is_err());
478    }
479
480    #[test]
481    fn test_generate_location() {
482        let location = Meta::generate_location("https://example.com", "User", "123");
483        assert_eq!(location, "https://example.com/Users/123");
484
485        // Test with trailing slash
486        let location = Meta::generate_location("https://example.com/", "Group", "456");
487        assert_eq!(location, "https://example.com/Groups/456");
488    }
489
490    #[test]
491    fn test_display() {
492        let created = Utc.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap();
493        let modified = Utc.with_ymd_and_hms(2023, 1, 2, 12, 0, 0).unwrap();
494
495        let meta = Meta::new_simple("User".to_string(), created, modified).unwrap();
496        let display_str = format!("{}", meta);
497
498        assert!(display_str.contains("User"));
499        assert!(display_str.contains("2023-01-01T12:00:00"));
500        assert!(display_str.contains("2023-01-02T12:00:00"));
501    }
502
503    #[test]
504    fn test_serialization() {
505        let created = Utc.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap();
506        let modified = Utc.with_ymd_and_hms(2023, 1, 2, 12, 0, 0).unwrap();
507
508        let meta = Meta::new(
509            "User".to_string(),
510            created,
511            modified,
512            Some("https://example.com/Users/123".to_string()),
513            Some("123-456".to_string()),
514        )
515        .unwrap();
516
517        let json = serde_json::to_string(&meta).unwrap();
518        assert!(json.contains("\"resourceType\":\"User\""));
519        assert!(json.contains("\"lastModified\""));
520        assert!(json.contains("\"location\":\"https://example.com/Users/123\""));
521        assert!(json.contains("\"version\":\"123-456\""));
522    }
523
524    #[test]
525    fn test_deserialization() {
526        let json = r#"{
527            "resourceType": "Group",
528            "created": "2023-01-01T12:00:00Z",
529            "lastModified": "2023-01-02T12:00:00Z",
530            "location": "https://example.com/Groups/456",
531            "version": "456-789"
532        }"#;
533
534        let meta: Meta = serde_json::from_str(json).unwrap();
535        assert_eq!(meta.resource_type(), "Group");
536        assert_eq!(meta.location(), Some("https://example.com/Groups/456"));
537        assert_eq!(meta.version(), Some("456-789"));
538    }
539
540    #[test]
541    fn test_equality() {
542        let created = Utc.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap();
543        let modified = Utc.with_ymd_and_hms(2023, 1, 2, 12, 0, 0).unwrap();
544
545        let meta1 = Meta::new_simple("User".to_string(), created, modified).unwrap();
546        let meta2 = Meta::new_simple("User".to_string(), created, modified).unwrap();
547        let meta3 = Meta::new_simple("Group".to_string(), created, modified).unwrap();
548
549        assert_eq!(meta1, meta2);
550        assert_ne!(meta1, meta3);
551    }
552
553    #[test]
554    fn test_clone() {
555        let created = Utc.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap();
556        let modified = Utc.with_ymd_and_hms(2023, 1, 2, 12, 0, 0).unwrap();
557
558        let meta = Meta::new(
559            "User".to_string(),
560            created,
561            modified,
562            Some("https://example.com/Users/123".to_string()),
563            Some("123-456".to_string()),
564        )
565        .unwrap();
566
567        let cloned = meta.clone();
568        assert_eq!(meta, cloned);
569        assert_eq!(meta.resource_type(), cloned.resource_type());
570        assert_eq!(meta.created(), cloned.created());
571        assert_eq!(meta.last_modified(), cloned.last_modified());
572        assert_eq!(meta.location(), cloned.location());
573        assert_eq!(meta.version(), cloned.version());
574    }
575}