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