scim_server/resource/
conditional_provider.rs

1//! Versioned resource types for SCIM resource versioning.
2//!
3//! This module provides types for handling versioned SCIM resources that support
4//! conditional operations with version control. As of Phase 3, conditional operations
5//! are mandatory and built into the core ResourceProvider trait, ensuring all providers
6//! support ETag-based concurrency control.
7//!
8//! # Mandatory Conditional Operations Architecture
9//!
10//! The SCIM server library now requires all ResourceProvider implementations to support
11//! conditional operations. This design decision provides:
12//!
13//! - **Universal Concurrency Control**: All resources automatically support ETag versioning
14//! - **Simplified Architecture**: Single code path with consistent behavior
15//! - **Type Safety**: Compile-time guarantees for version-aware operations
16//! - **Production Readiness**: Built-in protection against lost updates
17//!
18//! # Core Types
19//!
20//! * [`VersionedResource`] - Resource wrapper that includes automatic version computation
21//!
22//! # Usage with Mandatory Conditional Operations
23//!
24//! ```rust,no_run
25//! use scim_server::resource::{
26//!     provider::ResourceProvider,
27//!     conditional_provider::VersionedResource,
28//!     version::{ScimVersion, ConditionalResult},
29//!     core::{Resource, RequestContext},
30//! };
31//! use serde_json::Value;
32//! use std::collections::HashMap;
33//! use std::sync::Arc;
34//! use tokio::sync::RwLock;
35//!
36//! #[derive(Debug)]
37//! struct MyError(String);
38//! impl std::fmt::Display for MyError {
39//!     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40//!         write!(f, "{}", self.0)
41//!     }
42//! }
43//! impl std::error::Error for MyError {}
44//!
45//! #[derive(Clone)]
46//! struct MyProvider {
47//!     data: Arc<RwLock<HashMap<String, VersionedResource>>>,
48//! }
49//!
50//! impl ResourceProvider for MyProvider {
51//!     type Error = MyError;
52//!
53//!     // All providers must implement these core CRUD methods
54//!     async fn create_resource(&self, resource_type: &str, data: Value, context: &RequestContext) -> Result<Resource, Self::Error> {
55//!         let resource = Resource::from_json(resource_type.to_string(), data)
56//!             .map_err(|e| MyError(e.to_string()))?;
57//!         let mut store = self.data.write().await;
58//!         let id = resource.get_id().unwrap_or("generated-id").to_string();
59//!         let versioned = VersionedResource::new(resource.clone());
60//!         store.insert(id, versioned);
61//!         Ok(resource)
62//!     }
63//!
64//!     async fn get_resource(&self, _resource_type: &str, id: &str, _context: &RequestContext) -> Result<Option<Resource>, Self::Error> {
65//!         let store = self.data.read().await;
66//!         Ok(store.get(id).map(|v| v.resource().clone()))
67//!     }
68//!
69//!     // ... implement other required methods ...
70//!     # async fn update_resource(&self, _resource_type: &str, _id: &str, _data: Value, _context: &RequestContext) -> Result<Resource, Self::Error> {
71//!     #     todo!("Implement your update logic here")
72//!     # }
73//!     # async fn delete_resource(&self, _resource_type: &str, _id: &str, _context: &RequestContext) -> Result<(), Self::Error> {
74//!     #     todo!("Implement your delete logic here")
75//!     # }
76//!     # async fn list_resources(&self, _resource_type: &str, _query: Option<&scim_server::resource::core::ListQuery>, _context: &RequestContext) -> Result<Vec<Resource>, Self::Error> {
77//!     #     todo!("Implement your list logic here")
78//!     # }
79//!     # async fn find_resource_by_attribute(&self, _resource_type: &str, _attribute: &str, _value: &Value, _context: &RequestContext) -> Result<Option<Resource>, Self::Error> {
80//!     #     todo!("Implement your find logic here")
81//!     # }
82//!     # async fn resource_exists(&self, _resource_type: &str, _id: &str, _context: &RequestContext) -> Result<bool, Self::Error> {
83//!     #     todo!("Implement your exists logic here")
84//!     # }
85//!
86//!     // Conditional operations are MANDATORY - provided by default with automatic implementation
87//!     // Override these methods for optimized conditional operations at the storage layer:
88//!
89//!     // async fn conditional_update(&self, resource_type: &str, id: &str, data: Value,
90//!     //                           expected_version: &ScimVersion, context: &RequestContext)
91//!     //                           -> Result<ConditionalResult<VersionedResource>, Self::Error> {
92//!     //     // Your database-level conditional update with version checking
93//!     // }
94//!     //
95//!     // async fn conditional_delete(&self, resource_type: &str, id: &str,
96//!     //                           expected_version: &ScimVersion, context: &RequestContext)
97//!     //                           -> Result<ConditionalResult<()>, Self::Error> {
98//!     //     // Your database-level conditional delete with version checking
99//!     // }
100//! }
101//! ```
102//!
103//! # Architectural Benefits
104//!
105//! Making conditional operations mandatory provides several advantages:
106//!
107//! ## Simplified Codebase
108//! - Single code path for all operations
109//! - No optional/conditional provider detection
110//! - Consistent behavior across all implementations
111//!
112//! ## Enhanced Type Safety
113//! - Compile-time guarantees for version support
114//! - No runtime checks for capability detection
115//! - Clear API contracts for all providers
116//!
117//! ## Production Readiness
118//! - Built-in concurrency control for all resources
119//! - Automatic protection against lost updates
120//! - Enterprise-grade data integrity guarantees
121//!
122//! ## Developer Experience
123//! - Consistent APIs across all providers
124//! - Clear documentation and examples
125//! - Better IDE support and tooling
126
127use super::{core::Resource, version::ScimVersion};
128use serde::{Deserialize, Serialize};
129
130/// A resource with its associated version information.
131///
132/// This wrapper combines a SCIM resource with its version, enabling
133/// conditional operations that can detect concurrent modifications.
134/// The version is automatically computed from the resource content.
135///
136/// # Examples
137///
138/// ```rust
139/// use scim_server::resource::{
140///     conditional_provider::VersionedResource,
141///     core::Resource,
142/// };
143/// use serde_json::json;
144///
145/// let resource = Resource::from_json("User".to_string(), json!({
146///     "id": "123",
147///     "userName": "john.doe",
148///     "active": true
149/// })).unwrap();
150///
151/// let versioned = VersionedResource::new(resource);
152/// println!("Resource version: {}", versioned.version().to_http_header());
153/// ```
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct VersionedResource {
156    /// The SCIM resource data
157    resource: Resource,
158
159    /// The version computed from the resource content
160    version: ScimVersion,
161}
162
163impl VersionedResource {
164    /// Create a new versioned resource.
165    ///
166    /// The version is automatically computed from the resource's JSON representation,
167    /// ensuring consistency across all provider implementations.
168    ///
169    /// # Arguments
170    /// * `resource` - The SCIM resource
171    ///
172    /// # Examples
173    /// ```rust
174    /// use scim_server::resource::{
175    ///     conditional_provider::VersionedResource,
176    ///     core::Resource,
177    /// };
178    /// use serde_json::json;
179    ///
180    /// let resource = Resource::from_json("User".to_string(), json!({
181    ///     "id": "123",
182    ///     "userName": "john.doe"
183    /// })).unwrap();
184    ///
185    /// let versioned = VersionedResource::new(resource);
186    /// ```
187    pub fn new(resource: Resource) -> Self {
188        let version = Self::compute_version(&resource);
189        Self { resource, version }
190    }
191
192    /// Create a versioned resource with a specific version.
193    ///
194    /// This is useful when migrating from existing systems or when the version
195    /// needs to be preserved from external sources.
196    ///
197    /// # Arguments
198    /// * `resource` - The SCIM resource
199    /// * `version` - The specific version to use
200    ///
201    /// # Examples
202    /// ```rust
203    /// use scim_server::resource::{
204    ///     conditional_provider::VersionedResource,
205    ///     core::Resource,
206    ///     version::ScimVersion,
207    /// };
208    /// use serde_json::json;
209    ///
210    /// let resource = Resource::from_json("User".to_string(), json!({"id": "123"})).unwrap();
211    /// let version = ScimVersion::from_hash("custom-version");
212    /// let versioned = VersionedResource::with_version(resource, version);
213    /// ```
214    pub fn with_version(resource: Resource, version: ScimVersion) -> Self {
215        Self { resource, version }
216    }
217
218    /// Get the resource data.
219    pub fn resource(&self) -> &Resource {
220        &self.resource
221    }
222
223    /// Get the resource version.
224    pub fn version(&self) -> &ScimVersion {
225        &self.version
226    }
227
228    /// Convert into the underlying resource, discarding version information.
229    pub fn into_resource(self) -> Resource {
230        self.resource
231    }
232
233    /// Update the resource content and recompute the version.
234    ///
235    /// This ensures the version always reflects the current resource state.
236    ///
237    /// # Arguments
238    /// * `new_resource` - The updated resource data
239    ///
240    /// # Examples
241    /// ```rust
242    /// use scim_server::resource::{
243    ///     conditional_provider::VersionedResource,
244    ///     core::Resource,
245    /// };
246    /// use serde_json::json;
247    ///
248    /// let resource = Resource::from_json("User".to_string(), json!({"id": "123", "active": true})).unwrap();
249    /// let mut versioned = VersionedResource::new(resource);
250    ///
251    /// let updated = Resource::from_json("User".to_string(), json!({"id": "123", "active": false})).unwrap();
252    /// let old_version = versioned.version().clone();
253    /// versioned.update_resource(updated);
254    ///
255    /// assert!(!versioned.version().matches(&old_version));
256    /// ```
257    pub fn update_resource(&mut self, new_resource: Resource) {
258        self.version = Self::compute_version(&new_resource);
259        self.resource = new_resource;
260    }
261
262    /// Check if this resource's version matches the expected version.
263    ///
264    /// # Arguments
265    /// * `expected` - The expected version to check against
266    ///
267    /// # Returns
268    /// `true` if versions match, `false` otherwise
269    pub fn version_matches(&self, expected: &ScimVersion) -> bool {
270        self.version.matches(expected)
271    }
272
273    /// Refresh the version based on current resource content.
274    ///
275    /// This is useful if the resource was modified externally and the version
276    /// needs to be synchronized.
277    pub fn refresh_version(&mut self) {
278        self.version = Self::compute_version(&self.resource);
279    }
280
281    /// Compute version from resource content.
282    ///
283    /// This uses the resource's JSON representation to generate a consistent
284    /// hash-based version that reflects all resource data.
285    fn compute_version(resource: &Resource) -> ScimVersion {
286        let json_bytes = resource.to_json().unwrap().to_string().into_bytes();
287        ScimVersion::from_content(&json_bytes)
288    }
289}
290
291/// Historical note: Extension trait for conditional operations (Phase 1-2).
292///
293/// This trait was used during the development phases when conditional operations
294/// were optional. As of Phase 3, all conditional operations are mandatory and
295/// built into the core ResourceProvider trait.
296///
297/// # Migration to Mandatory Architecture
298///
299/// The library has evolved from optional conditional operations to mandatory ones:
300///
301/// - **Phase 1-2**: Conditional operations were optional via this extension trait
302/// - **Phase 3**: Conditional operations moved to core ResourceProvider trait
303/// - **Current**: All providers automatically support conditional operations
304///
305/// This change ensures:
306/// - Universal concurrency control for all SCIM resources
307/// - Simplified integration with automatic ETag support
308/// - Consistent behavior across different provider implementations
309/// - Production-ready concurrency control out of the box
310///
311/// All new code should use the conditional methods directly on ResourceProvider
312/// rather than this historical extension trait.
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use serde_json::json;
318
319    #[test]
320    fn test_versioned_resource_creation() {
321        let 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 versioned = VersionedResource::new(resource.clone());
332        assert_eq!(versioned.resource().get_id(), resource.get_id());
333        assert!(!versioned.version().as_str().is_empty());
334    }
335
336    #[test]
337    fn test_versioned_resource_version_changes() {
338        let resource1 = Resource::from_json(
339            "User".to_string(),
340            json!({
341                "id": "123",
342                "userName": "john.doe",
343                "active": true
344            }),
345        )
346        .unwrap();
347
348        let resource2 = Resource::from_json(
349            "User".to_string(),
350            json!({
351                "id": "123",
352                "userName": "john.doe",
353                "active": false // Changed field
354            }),
355        )
356        .unwrap();
357
358        let versioned1 = VersionedResource::new(resource1);
359        let versioned2 = VersionedResource::new(resource2);
360
361        // Different content should produce different versions
362        assert!(!versioned1.version().matches(versioned2.version()));
363    }
364
365    #[test]
366    fn test_versioned_resource_update() {
367        let initial_resource = Resource::from_json(
368            "User".to_string(),
369            json!({
370                "id": "123",
371                "userName": "john.doe",
372                "active": true
373            }),
374        )
375        .unwrap();
376
377        let mut versioned = VersionedResource::new(initial_resource);
378        let old_version = versioned.version().clone();
379
380        let updated_resource = Resource::from_json(
381            "User".to_string(),
382            json!({
383                "id": "123",
384                "userName": "john.doe",
385                "active": false
386            }),
387        )
388        .unwrap();
389
390        versioned.update_resource(updated_resource);
391
392        // Version should change after update
393        assert!(!versioned.version().matches(&old_version));
394        assert_eq!(versioned.resource().get_id(), Some("123"));
395    }
396
397    #[test]
398    fn test_versioned_resource_version_matching() {
399        let resource = Resource::from_json(
400            "User".to_string(),
401            json!({
402                "id": "123",
403                "userName": "test"
404            }),
405        )
406        .unwrap();
407
408        let versioned = VersionedResource::new(resource);
409        let version_copy = versioned.version().clone();
410        let different_version = ScimVersion::from_hash("different");
411
412        assert!(versioned.version_matches(&version_copy));
413        assert!(!versioned.version_matches(&different_version));
414    }
415
416    #[test]
417    fn test_versioned_resource_with_version() {
418        let resource = Resource::from_json("User".to_string(), json!({"id": "123"})).unwrap();
419        let custom_version = ScimVersion::from_hash("custom-version-123");
420
421        let versioned = VersionedResource::with_version(resource.clone(), custom_version.clone());
422
423        assert_eq!(versioned.resource().get_id(), resource.get_id());
424        assert_eq!(versioned.version(), &custom_version);
425    }
426
427    #[test]
428    fn test_versioned_resource_refresh_version() {
429        let resource =
430            Resource::from_json("User".to_string(), json!({"id": "123", "data": "test"})).unwrap();
431        let custom_version = ScimVersion::from_hash("custom");
432
433        let mut versioned = VersionedResource::with_version(resource, custom_version.clone());
434        assert_eq!(versioned.version(), &custom_version);
435
436        versioned.refresh_version();
437        // After refresh, version should be computed from content, not the custom version
438        assert!(!versioned.version().matches(&custom_version));
439    }
440
441    #[test]
442    fn test_versioned_resource_serialization() {
443        let resource = Resource::from_json(
444            "User".to_string(),
445            json!({
446                "id": "123",
447                "userName": "test.user"
448            }),
449        )
450        .unwrap();
451
452        let versioned = VersionedResource::new(resource);
453
454        // Test JSON serialization round-trip
455        let json = serde_json::to_string(&versioned).unwrap();
456        let deserialized: VersionedResource = serde_json::from_str(&json).unwrap();
457
458        assert_eq!(
459            versioned.resource().get_id(),
460            deserialized.resource().get_id()
461        );
462        assert!(versioned.version().matches(deserialized.version()));
463    }
464}