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("W/\"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 (ETag 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    /// Generate an ETag version identifier.
214    ///
215    /// Creates a weak ETag version identifier based on the resource ID and
216    /// last modified timestamp.
217    pub fn generate_version(resource_id: &str, last_modified: DateTime<Utc>) -> String {
218        let timestamp = last_modified.timestamp_millis();
219        format!("W/\"{}-{}\"", resource_id, timestamp)
220    }
221
222    /// Validate the resource type value.
223    fn validate_resource_type(resource_type: &str) -> ValidationResult<()> {
224        if resource_type.is_empty() {
225            return Err(ValidationError::MissingResourceType);
226        }
227
228        // Resource type should be a valid identifier
229        if !resource_type
230            .chars()
231            .all(|c| c.is_alphanumeric() || c == '_')
232        {
233            return Err(ValidationError::InvalidResourceType {
234                resource_type: resource_type.to_string(),
235            });
236        }
237
238        Ok(())
239    }
240
241    /// Validate the timestamp values.
242    fn validate_timestamps(
243        created: DateTime<Utc>,
244        last_modified: DateTime<Utc>,
245    ) -> ValidationResult<()> {
246        if last_modified < created {
247            return Err(ValidationError::Custom {
248                message: "Last modified timestamp cannot be before created timestamp".to_string(),
249            });
250        }
251
252        Ok(())
253    }
254
255    /// Validate the location URI value.
256    fn validate_location(location: &str) -> ValidationResult<()> {
257        if location.is_empty() {
258            return Err(ValidationError::InvalidLocationUri);
259        }
260
261        // Basic URI validation - should start with http:// or https://
262        if !location.starts_with("http://") && !location.starts_with("https://") {
263            return Err(ValidationError::InvalidLocationUri);
264        }
265
266        Ok(())
267    }
268
269    /// Validate the version identifier value.
270    fn validate_version(version: &str) -> ValidationResult<()> {
271        if version.is_empty() {
272            return Err(ValidationError::InvalidVersionFormat);
273        }
274
275        // Version should follow ETag format: W/"..." or "..."
276        if !version.starts_with("W/\"") && !version.starts_with('"') {
277            return Err(ValidationError::InvalidVersionFormat);
278        }
279
280        if !version.ends_with('"') {
281            return Err(ValidationError::InvalidVersionFormat);
282        }
283
284        Ok(())
285    }
286}
287
288impl fmt::Display for Meta {
289    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
290        write!(
291            f,
292            "Meta(resourceType={}, created={}, lastModified={})",
293            self.resource_type,
294            self.created.to_rfc3339(),
295            self.last_modified.to_rfc3339()
296        )
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use chrono::{TimeZone, Utc};
304    use serde_json;
305
306    #[test]
307    fn test_valid_meta_full() {
308        let created = Utc.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap();
309        let modified = Utc.with_ymd_and_hms(2023, 1, 2, 12, 0, 0).unwrap();
310
311        let meta = Meta::new(
312            "User".to_string(),
313            created,
314            modified,
315            Some("https://example.com/Users/123".to_string()),
316            Some("W/\"123-456\"".to_string()),
317        );
318        assert!(meta.is_ok());
319
320        let meta = meta.unwrap();
321        assert_eq!(meta.resource_type(), "User");
322        assert_eq!(meta.created(), created);
323        assert_eq!(meta.last_modified(), modified);
324        assert_eq!(meta.location(), Some("https://example.com/Users/123"));
325        assert_eq!(meta.version(), Some("W/\"123-456\""));
326    }
327
328    #[test]
329    fn test_valid_meta_simple() {
330        let created = Utc.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap();
331        let modified = Utc.with_ymd_and_hms(2023, 1, 1, 12, 30, 0).unwrap();
332
333        let meta = Meta::new_simple("Group".to_string(), created, modified);
334        assert!(meta.is_ok());
335
336        let meta = meta.unwrap();
337        assert_eq!(meta.resource_type(), "Group");
338        assert_eq!(meta.created(), created);
339        assert_eq!(meta.last_modified(), modified);
340        assert_eq!(meta.location(), None);
341        assert_eq!(meta.version(), None);
342    }
343
344    #[test]
345    fn test_new_for_creation() {
346        let meta = Meta::new_for_creation("User".to_string());
347        assert!(meta.is_ok());
348
349        let meta = meta.unwrap();
350        assert_eq!(meta.resource_type(), "User");
351        assert_eq!(meta.created(), meta.last_modified());
352    }
353
354    #[test]
355    fn test_empty_resource_type() {
356        let now = Utc::now();
357        let result = Meta::new_simple("".to_string(), now, now);
358        assert!(result.is_err());
359
360        match result.unwrap_err() {
361            ValidationError::MissingResourceType => {}
362            other => panic!("Expected MissingResourceType error, got: {:?}", other),
363        }
364    }
365
366    #[test]
367    fn test_invalid_resource_type() {
368        let now = Utc::now();
369        let result = Meta::new_simple("Invalid-Type!".to_string(), now, now);
370        assert!(result.is_err());
371
372        match result.unwrap_err() {
373            ValidationError::InvalidResourceType { resource_type } => {
374                assert_eq!(resource_type, "Invalid-Type!");
375            }
376            other => panic!("Expected InvalidResourceType error, got: {:?}", other),
377        }
378    }
379
380    #[test]
381    fn test_invalid_timestamps() {
382        let created = Utc.with_ymd_and_hms(2023, 1, 2, 12, 0, 0).unwrap();
383        let modified = Utc.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap(); // Before created
384
385        let result = Meta::new_simple("User".to_string(), created, modified);
386        assert!(result.is_err());
387
388        match result.unwrap_err() {
389            ValidationError::Custom { message } => {
390                assert!(message.contains("Last modified timestamp cannot be before created"));
391            }
392            other => panic!("Expected Custom error, got: {:?}", other),
393        }
394    }
395
396    #[test]
397    fn test_invalid_location() {
398        let now = Utc::now();
399
400        // Empty location
401        let result = Meta::new("User".to_string(), now, now, Some("".to_string()), None);
402        assert!(result.is_err());
403
404        // Invalid URI format
405        let result = Meta::new(
406            "User".to_string(),
407            now,
408            now,
409            Some("not-a-uri".to_string()),
410            None,
411        );
412        assert!(result.is_err());
413    }
414
415    #[test]
416    fn test_invalid_version() {
417        let now = Utc::now();
418
419        // Empty version
420        let result = Meta::new("User".to_string(), now, now, None, Some("".to_string()));
421        assert!(result.is_err());
422
423        // Invalid ETag format
424        let result = Meta::new(
425            "User".to_string(),
426            now,
427            now,
428            None,
429            Some("invalid-etag".to_string()),
430        );
431        assert!(result.is_err());
432
433        match result.unwrap_err() {
434            ValidationError::InvalidVersionFormat => {}
435            other => panic!("Expected InvalidVersionFormat error, got: {:?}", other),
436        }
437    }
438
439    #[test]
440    fn test_with_updated_timestamp() {
441        let created = Utc.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap();
442        let meta = Meta::new_simple("User".to_string(), created, created).unwrap();
443
444        std::thread::sleep(std::time::Duration::from_millis(10));
445        let updated_meta = meta.with_updated_timestamp();
446
447        assert_eq!(updated_meta.created(), created);
448        assert!(updated_meta.last_modified() > created);
449        assert_eq!(updated_meta.resource_type(), "User");
450    }
451
452    #[test]
453    fn test_with_location() {
454        let now = Utc::now();
455        let meta = Meta::new_simple("User".to_string(), now, now).unwrap();
456
457        let meta_with_location = meta
458            .clone()
459            .with_location("https://example.com/Users/123".to_string());
460        assert!(meta_with_location.is_ok());
461
462        let meta_with_location = meta_with_location.unwrap();
463        assert_eq!(
464            meta_with_location.location(),
465            Some("https://example.com/Users/123")
466        );
467
468        // Test invalid location
469        let invalid_result = meta.with_location("invalid-uri".to_string());
470        assert!(invalid_result.is_err());
471    }
472
473    #[test]
474    fn test_with_version() {
475        let now = Utc::now();
476        let meta = Meta::new_simple("User".to_string(), now, now).unwrap();
477
478        let meta_with_version = meta.clone().with_version("W/\"123-456\"".to_string());
479        assert!(meta_with_version.is_ok());
480
481        let meta_with_version = meta_with_version.unwrap();
482        assert_eq!(meta_with_version.version(), Some("W/\"123-456\""));
483
484        // Test invalid version
485        let invalid_result = meta.with_version("invalid-version".to_string());
486        assert!(invalid_result.is_err());
487    }
488
489    #[test]
490    fn test_generate_location() {
491        let location = Meta::generate_location("https://example.com", "User", "123");
492        assert_eq!(location, "https://example.com/Users/123");
493
494        // Test with trailing slash
495        let location = Meta::generate_location("https://example.com/", "Group", "456");
496        assert_eq!(location, "https://example.com/Groups/456");
497    }
498
499    #[test]
500    fn test_generate_version() {
501        let timestamp = Utc.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap();
502        let version = Meta::generate_version("123", timestamp);
503        let expected_millis = timestamp.timestamp_millis();
504        assert_eq!(version, format!("W/\"123-{}\"", expected_millis));
505    }
506
507    #[test]
508    fn test_display() {
509        let created = Utc.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap();
510        let modified = Utc.with_ymd_and_hms(2023, 1, 2, 12, 0, 0).unwrap();
511
512        let meta = Meta::new_simple("User".to_string(), created, modified).unwrap();
513        let display_str = format!("{}", meta);
514
515        assert!(display_str.contains("User"));
516        assert!(display_str.contains("2023-01-01T12:00:00"));
517        assert!(display_str.contains("2023-01-02T12:00:00"));
518    }
519
520    #[test]
521    fn test_serialization() {
522        let created = Utc.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap();
523        let modified = Utc.with_ymd_and_hms(2023, 1, 2, 12, 0, 0).unwrap();
524
525        let meta = Meta::new(
526            "User".to_string(),
527            created,
528            modified,
529            Some("https://example.com/Users/123".to_string()),
530            Some("W/\"123-456\"".to_string()),
531        )
532        .unwrap();
533
534        let json = serde_json::to_string(&meta).unwrap();
535        assert!(json.contains("\"resourceType\":\"User\""));
536        assert!(json.contains("\"lastModified\""));
537        assert!(json.contains("\"location\":\"https://example.com/Users/123\""));
538        assert!(json.contains("\"version\":\"W/\\\"123-456\\\"\""));
539    }
540
541    #[test]
542    fn test_deserialization() {
543        let json = r#"{
544            "resourceType": "Group",
545            "created": "2023-01-01T12:00:00Z",
546            "lastModified": "2023-01-02T12:00:00Z",
547            "location": "https://example.com/Groups/456",
548            "version": "W/\"456-789\""
549        }"#;
550
551        let meta: Meta = serde_json::from_str(json).unwrap();
552        assert_eq!(meta.resource_type(), "Group");
553        assert_eq!(meta.location(), Some("https://example.com/Groups/456"));
554        assert_eq!(meta.version(), Some("W/\"456-789\""));
555    }
556
557    #[test]
558    fn test_equality() {
559        let created = Utc.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap();
560        let modified = Utc.with_ymd_and_hms(2023, 1, 2, 12, 0, 0).unwrap();
561
562        let meta1 = Meta::new_simple("User".to_string(), created, modified).unwrap();
563        let meta2 = Meta::new_simple("User".to_string(), created, modified).unwrap();
564        let meta3 = Meta::new_simple("Group".to_string(), created, modified).unwrap();
565
566        assert_eq!(meta1, meta2);
567        assert_ne!(meta1, meta3);
568    }
569
570    #[test]
571    fn test_clone() {
572        let created = Utc.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap();
573        let modified = Utc.with_ymd_and_hms(2023, 1, 2, 12, 0, 0).unwrap();
574
575        let meta = Meta::new(
576            "User".to_string(),
577            created,
578            modified,
579            Some("https://example.com/Users/123".to_string()),
580            Some("W/\"123-456\"".to_string()),
581        )
582        .unwrap();
583
584        let cloned = meta.clone();
585        assert_eq!(meta, cloned);
586        assert_eq!(meta.resource_type(), cloned.resource_type());
587        assert_eq!(meta.created(), cloned.created());
588        assert_eq!(meta.last_modified(), cloned.last_modified());
589        assert_eq!(meta.location(), cloned.location());
590        assert_eq!(meta.version(), cloned.version());
591    }
592}