scim_server/resource/
versioned.rs

1//! Versioned resource types for SCIM resource versioning.
2//!
3//! This module provides the `VersionedResource` type for handling SCIM resources
4//! with version control. It enables conditional operations with ETag-based
5//! concurrency control for preventing lost updates.
6//!
7//! # Core Type
8//!
9//! * [`VersionedResource`] - Resource wrapper that includes automatic version computation
10//!
11//! # Usage
12//!
13//! ```rust
14//! use scim_server::resource::{
15//!     versioned::VersionedResource,
16//!     Resource,
17//! };
18//! use scim_server::resource::version::HttpVersion;
19//! use serde_json::json;
20//!
21//! let resource = Resource::from_json("User".to_string(), json!({
22//!     "id": "123",
23//!     "userName": "john.doe",
24//!     "active": true
25//! })).unwrap();
26//!
27//! let versioned = VersionedResource::new(resource);
28//! println!(
29//!     "Resource version: {}",
30//!     HttpVersion::from(versioned.version().clone())
31//! );
32//! ```
33
34use super::{
35    resource::Resource,
36    version::{RawVersion, ScimVersion},
37};
38use serde::{Deserialize, Serialize};
39
40/// A resource with its associated version information.
41///
42/// This wrapper combines a SCIM resource with its version, enabling
43/// conditional operations that can detect concurrent modifications.
44/// The version is automatically computed from the resource content.
45///
46/// # Examples
47///
48/// ```rust
49/// use scim_server::resource::{
50///     versioned::VersionedResource,
51///     Resource,
52/// };
53/// use scim_server::resource::version::HttpVersion;
54/// use serde_json::json;
55///
56/// let resource = Resource::from_json("User".to_string(), json!({
57///     "id": "123",
58///     "userName": "john.doe",
59///     "active": true
60/// })).unwrap();
61///
62/// let versioned = VersionedResource::new(resource);
63/// println!(
64///     "Resource version: {}",
65///     HttpVersion::from(versioned.version().clone())
66/// );
67/// ```
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct VersionedResource {
70    /// The SCIM resource data
71    resource: Resource,
72
73    /// The version computed from the resource content
74    version: RawVersion,
75}
76
77impl VersionedResource {
78    /// Create a new versioned resource.
79    ///
80    /// The version is automatically computed from the resource's JSON representation,
81    /// ensuring consistency across all provider implementations.
82    ///
83    /// # Arguments
84    /// * `resource` - The SCIM resource
85    ///
86    /// # Examples
87    /// ```rust
88    /// use scim_server::resource::{
89    ///     versioned::VersionedResource,
90    ///     Resource,
91    /// };
92    /// use serde_json::json;
93    ///
94    /// let resource = Resource::from_json("User".to_string(), json!({
95    ///     "id": "123",
96    ///     "userName": "john.doe"
97    /// })).unwrap();
98    ///
99    /// let versioned = VersionedResource::new(resource);
100    /// ```
101    pub fn new(resource: Resource) -> Self {
102        let version = Self::get_or_compute_version(&resource);
103        Self { resource, version }
104    }
105
106    /// Create a versioned resource with a specific version.
107    ///
108    /// This is useful when migrating from existing systems or when the version
109    /// needs to be preserved from external sources.
110    ///
111    /// # Arguments
112    /// * `resource` - The SCIM resource
113    /// * `version` - The specific version to use
114    ///
115    /// # Examples
116    /// ```rust
117    /// use scim_server::resource::{
118    ///     versioned::VersionedResource,
119    ///     Resource,
120    ///     version::RawVersion,
121    /// };
122    /// use serde_json::json;
123    ///
124    /// let resource = Resource::from_json("User".to_string(), json!({"id": "123"})).unwrap();
125    /// let version = RawVersion::from_hash("custom-version");
126    /// let versioned = VersionedResource::with_version(resource, version);
127    /// ```
128    pub fn with_version(resource: Resource, version: RawVersion) -> Self {
129        Self { resource, version }
130    }
131
132    /// Get the resource data.
133    pub fn resource(&self) -> &Resource {
134        &self.resource
135    }
136
137    /// Get the resource version.
138    pub fn version(&self) -> &RawVersion {
139        &self.version
140    }
141
142    /// Convert into the underlying resource, discarding version information.
143    pub fn into_resource(self) -> Resource {
144        self.resource
145    }
146
147    /// Get the unique identifier of this resource.
148    ///
149    /// Delegates to the inner resource's `get_id()` method.
150    pub fn get_id(&self) -> Option<&str> {
151        self.resource.get_id()
152    }
153
154    /// Get the userName field for User resources.
155    ///
156    /// Delegates to the inner resource's `get_username()` method.
157    pub fn get_username(&self) -> Option<&str> {
158        self.resource.get_username()
159    }
160
161    /// Get the external id if present.
162    ///
163    /// Delegates to the inner resource's `get_external_id()` method.
164    pub fn get_external_id(&self) -> Option<&str> {
165        self.resource.get_external_id()
166    }
167
168    /// Get the meta attributes if present.
169    ///
170    /// Delegates to the inner resource's `get_meta()` method.
171    pub fn get_meta(&self) -> Option<&crate::resource::value_objects::Meta> {
172        self.resource.get_meta()
173    }
174
175    /// Get an attribute value from the resource.
176    ///
177    /// Delegates to the inner resource's `get()` method.
178    pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
179        self.resource.get(key)
180    }
181
182    /// Get an attribute value from the resource.
183    ///
184    /// Delegates to the inner resource's `get_attribute()` method.
185    /// This is an alias for `get()` for consistency with Resource API.
186    pub fn get_attribute(&self, attribute_name: &str) -> Option<&serde_json::Value> {
187        self.resource.get_attribute(attribute_name)
188    }
189
190    /// Update the resource content and recompute the version.
191    ///
192    /// This ensures the version always reflects the current resource state.
193    ///
194    /// # Arguments
195    /// * `new_resource` - The updated resource data
196    ///
197    /// # Examples
198    /// ```rust
199    /// use scim_server::resource::{
200    ///     versioned::VersionedResource,
201    ///     Resource,
202    /// };
203    /// use serde_json::json;
204    ///
205    /// let resource = Resource::from_json("User".to_string(), json!({"id": "123", "active": true})).unwrap();
206    /// let mut versioned = VersionedResource::new(resource);
207    ///
208    /// let updated = Resource::from_json("User".to_string(), json!({"id": "123", "active": false})).unwrap();
209    /// let old_version = versioned.version().clone();
210    /// versioned.update_resource(updated);
211    ///
212    /// assert!(versioned.version() != &old_version);
213    /// ```
214    pub fn update_resource(&mut self, new_resource: Resource) {
215        self.version = Self::compute_version(&new_resource);
216        self.resource = new_resource;
217    }
218
219    /// Check if this resource's version matches the expected version.
220    ///
221    /// # Arguments
222    /// * `expected` - The expected version to check against
223    ///
224    /// # Returns
225    /// `true` if versions match, `false` otherwise
226    pub fn version_matches<F>(&self, expected: &ScimVersion<F>) -> bool {
227        self.version == *expected
228    }
229
230    /// Refresh the version based on current resource content.
231    ///
232    /// This is useful if the resource was modified externally and the version
233    /// needs to be synchronized.
234    pub fn refresh_version(&mut self) {
235        self.version = Self::compute_version(&self.resource);
236    }
237
238    /// Get version from resource meta or compute from content if not available.
239    ///
240    /// This first tries to extract the version from the resource's meta field.
241    /// Meta now stores versions in raw format internally.
242    /// If no version exists in meta, it computes one from the resource content.
243    fn get_or_compute_version(resource: &Resource) -> RawVersion {
244        // Try to get version from meta first (now stored in raw format)
245        if let Some(meta) = resource.get_meta() {
246            if let Some(meta_version) = meta.version() {
247                // Meta now stores raw versions, so parse directly
248                if let Ok(version) = meta_version.parse::<RawVersion>() {
249                    return version;
250                }
251            }
252        }
253
254        // Fallback: compute version from content
255        Self::compute_version(resource)
256    }
257
258    /// Compute version from resource content.
259    ///
260    /// This uses the resource's JSON representation to generate a consistent
261    /// hash-based version that reflects all resource data.
262    fn compute_version(resource: &Resource) -> RawVersion {
263        let json_bytes = resource.to_json().unwrap().to_string().into_bytes();
264        RawVersion::from_content(&json_bytes)
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use serde_json::json;
272
273    #[test]
274    fn test_versioned_resource_creation() {
275        let resource = Resource::from_json(
276            "User".to_string(),
277            json!({
278                "id": "123",
279                "userName": "john.doe",
280                "active": true
281            }),
282        )
283        .unwrap();
284
285        let versioned = VersionedResource::new(resource.clone());
286        assert_eq!(versioned.get_id(), resource.get_id());
287        assert!(!versioned.version().as_str().is_empty());
288    }
289
290    #[test]
291    fn test_versioned_resource_version_changes() {
292        let resource1 = Resource::from_json(
293            "User".to_string(),
294            json!({
295                "id": "123",
296                "userName": "john.doe",
297                "active": true
298            }),
299        )
300        .unwrap();
301
302        let resource2 = Resource::from_json(
303            "User".to_string(),
304            json!({
305                "id": "123",
306                "userName": "john.doe",
307                "active": false // Changed field
308            }),
309        )
310        .unwrap();
311
312        let versioned1 = VersionedResource::new(resource1);
313        let versioned2 = VersionedResource::new(resource2);
314
315        // Different content should produce different versions
316        assert!(versioned1.version() != versioned2.version());
317    }
318
319    #[test]
320    fn test_versioned_resource_update() {
321        let initial_resource = Resource::from_json(
322            "User".to_string(),
323            json!({
324                "id": "123",
325                "userName": "john.doe",
326                "active": true
327            }),
328        )
329        .unwrap();
330
331        let mut versioned = VersionedResource::new(initial_resource);
332        let old_version = versioned.version().clone();
333
334        let updated_resource = Resource::from_json(
335            "User".to_string(),
336            json!({
337                "id": "123",
338                "userName": "john.doe",
339                "active": false
340            }),
341        )
342        .unwrap();
343
344        versioned.update_resource(updated_resource);
345
346        // Version should change after update
347        assert!(versioned.version() != &old_version);
348        assert_eq!(versioned.get_id(), Some("123"));
349    }
350
351    #[test]
352    fn test_versioned_resource_version_matching() {
353        let resource = Resource::from_json(
354            "User".to_string(),
355            json!({
356                "id": "123",
357                "userName": "test"
358            }),
359        )
360        .unwrap();
361
362        let versioned = VersionedResource::new(resource);
363        let version_copy = versioned.version().clone();
364        let different_version = RawVersion::from_hash("different");
365
366        assert!(versioned.version_matches(&version_copy));
367        assert!(!versioned.version_matches(&different_version));
368    }
369
370    #[test]
371    fn test_versioned_resource_with_version() {
372        let resource = Resource::from_json("User".to_string(), json!({"id": "123"})).unwrap();
373        let custom_version = RawVersion::from_hash("custom-version-123");
374
375        let versioned = VersionedResource::with_version(resource.clone(), custom_version.clone());
376
377        assert_eq!(versioned.get_id(), resource.get_id());
378        assert_eq!(versioned.version(), &custom_version);
379    }
380
381    #[test]
382    fn test_versioned_resource_refresh_version() {
383        let resource =
384            Resource::from_json("User".to_string(), json!({"id": "123", "data": "test"})).unwrap();
385        let custom_version = RawVersion::from_hash("custom");
386
387        let mut versioned = VersionedResource::with_version(resource, custom_version.clone());
388        assert_eq!(versioned.version(), &custom_version);
389
390        versioned.refresh_version();
391        // After refresh, version should be computed from content, not the custom version
392        assert!(versioned.version() != &custom_version);
393    }
394
395    #[test]
396    fn test_versioned_resource_serialization() {
397        let resource = Resource::from_json(
398            "User".to_string(),
399            json!({
400                "id": "123",
401                "userName": "test.user"
402            }),
403        )
404        .unwrap();
405
406        let versioned = VersionedResource::new(resource);
407
408        // Test JSON serialization round-trip
409        let json = serde_json::to_string(&versioned).unwrap();
410        let deserialized: VersionedResource = serde_json::from_str(&json).unwrap();
411
412        assert_eq!(versioned.get_id(), deserialized.get_id());
413        assert!(versioned.version() == deserialized.version());
414    }
415}