scim_server/providers/helpers/
conditional.rs

1//! Conditional operations helper trait for SCIM resources.
2//!
3//! This module provides reusable functionality for implementing version-based optimistic
4//! concurrency control in SCIM ResourceProvider implementations. It handles version
5//! computation, conflict detection, and conditional operation patterns.
6//!
7//! # Optimistic Concurrency Control
8//!
9//! This implementation follows SCIM and HTTP ETag patterns for:
10//! - Version-based conditional updates and deletes
11//! - Conflict detection and resolution
12//! - Version computation using content hashing
13//! - ConditionalResult handling for operation outcomes
14//!
15//! # Usage
16//!
17//! ```rust,no_run
18//! use scim_server::resource::version::{RawVersion, ConditionalResult};
19//!
20//! // ConditionalOperations provides methods like conditional_update_resource
21//! // that prevent lost updates through optimistic locking
22//! let expected_version = RawVersion::from_hash("abc123");
23//!
24//! // The trait automatically implements conditional operations
25//! // for any type that implements ResourceProvider
26//! ```
27
28use crate::providers::ResourceProvider;
29use crate::resource::RequestContext;
30use crate::resource::version::{ConditionalResult, RawVersion, VersionConflict};
31use crate::resource::versioned::VersionedResource;
32use serde_json::Value;
33use std::future::Future;
34
35/// Trait providing version-based conditional operations for SCIM resources.
36///
37/// This trait extends ResourceProvider with optimistic concurrency control capabilities
38/// including conditional updates, deletes, and version management. Most implementers
39/// can use the default implementations which provide standard conditional operation patterns.
40pub trait ConditionalOperations: ResourceProvider {
41    /// Perform a conditional update operation.
42    ///
43    /// Updates a resource only if the current version matches the expected version,
44    /// preventing lost updates in concurrent scenarios. Uses optimistic locking
45    /// based on resource content versioning.
46    ///
47    /// # Arguments
48    /// * `resource_type` - The type of resource to update
49    /// * `id` - The unique identifier of the resource
50    /// * `data` - The updated resource data
51    /// * `expected_version` - The version the client expects (for conflict detection)
52    /// * `context` - Request context containing tenant information
53    ///
54    /// # Returns
55    /// * `Success(VersionedResource)` - Update succeeded with new resource and version
56    /// * `VersionMismatch(VersionConflict)` - Resource was modified by another client
57    /// * `NotFound` - Resource doesn't exist
58    ///
59    /// # Example
60    /// ```rust,no_run
61    /// use scim_server::resource::version::{RawVersion, ConditionalResult};
62    /// use serde_json::json;
63    ///
64    /// let expected_version = RawVersion::from_hash("abc123");
65    /// let update_data = json!({"userName": "newname", "active": false});
66    ///
67    /// // ConditionalOperations automatically available on ResourceProvider implementations
68    /// // match provider.conditional_update_resource("Users", "123", update_data, &expected_version, &context).await? {
69    /// //     ConditionalResult::Success(versioned) => println!("Update successful"),
70    /// //     ConditionalResult::VersionMismatch(conflict) => println!("Conflict detected"),
71    /// //     ConditionalResult::NotFound => println!("Resource not found"),
72    /// // }
73    /// ```
74    fn conditional_update_resource(
75        &self,
76        resource_type: &str,
77        id: &str,
78        data: Value,
79        expected_version: &RawVersion,
80        context: &RequestContext,
81    ) -> impl Future<Output = Result<ConditionalResult<VersionedResource>, Self::Error>> + Send
82    where
83        Self: Sync,
84    {
85        async move {
86            // Get current resource
87            match self.get_resource(resource_type, id, context).await? {
88                Some(current_resource) => {
89                    // Create versioned resource to get current version
90                    let versioned_current = current_resource;
91                    let current_version = versioned_current.version();
92
93                    // Check if versions match
94                    if current_version != expected_version {
95                        return Ok(ConditionalResult::VersionMismatch(
96                            VersionConflict::standard_message(
97                                expected_version.clone(),
98                                current_version.clone(),
99                            ),
100                        ));
101                    }
102
103                    // Version matches, proceed with update
104                    match self
105                        .update_resource(resource_type, id, data, None, context)
106                        .await
107                    {
108                        Ok(updated_resource) => {
109                            let versioned_result = updated_resource;
110                            Ok(ConditionalResult::Success(versioned_result))
111                        }
112                        Err(e) => Err(e),
113                    }
114                }
115                None => Ok(ConditionalResult::NotFound),
116            }
117        }
118    }
119
120    /// Perform a conditional delete operation.
121    ///
122    /// Deletes a resource only if the current version matches the expected version,
123    /// preventing accidental deletion of modified resources.
124    ///
125    /// # Arguments
126    /// * `resource_type` - The type of resource to delete
127    /// * `id` - The unique identifier of the resource
128    /// * `expected_version` - The version the client expects (for conflict detection)
129    /// * `context` - Request context containing tenant information
130    ///
131    /// # Returns
132    /// * `Success(())` - Delete succeeded
133    /// * `VersionMismatch(VersionConflict)` - Resource was modified by another client
134    /// * `NotFound` - Resource doesn't exist
135    fn conditional_delete_resource(
136        &self,
137        resource_type: &str,
138        id: &str,
139        expected_version: &RawVersion,
140        context: &RequestContext,
141    ) -> impl Future<Output = Result<ConditionalResult<()>, Self::Error>> + Send
142    where
143        Self: Sync,
144    {
145        async move {
146            // Get current resource
147            match self.get_resource(resource_type, id, context).await? {
148                Some(current_resource) => {
149                    // Create versioned resource to get current version
150                    let versioned_current = current_resource;
151                    let current_version = versioned_current.version();
152
153                    // Check if versions match
154                    if current_version != expected_version {
155                        return Ok(ConditionalResult::VersionMismatch(
156                            VersionConflict::standard_message(
157                                expected_version.clone(),
158                                current_version.clone(),
159                            ),
160                        ));
161                    }
162
163                    // Version matches, proceed with delete
164                    match self.delete_resource(resource_type, id, None, context).await {
165                        Ok(_) => Ok(ConditionalResult::Success(())),
166                        Err(e) => Err(e),
167                    }
168                }
169                None => Ok(ConditionalResult::NotFound),
170            }
171        }
172    }
173
174    /// Perform a conditional PATCH operation.
175    ///
176    /// Applies PATCH operations to a resource only if the current version matches
177    /// the expected version, combining version control with incremental updates.
178    ///
179    /// # Arguments
180    /// * `resource_type` - The type of resource to patch
181    /// * `id` - The unique identifier of the resource
182    /// * `patch_request` - The PATCH operations to apply
183    /// * `expected_version` - The version the client expects (for conflict detection)
184    /// * `context` - Request context containing tenant information
185    ///
186    /// # Returns
187    /// * `Success(VersionedResource)` - PATCH succeeded with updated resource and version
188    /// * `VersionMismatch(VersionConflict)` - Resource was modified by another client
189    /// * `NotFound` - Resource doesn't exist
190    fn conditional_patch_resource(
191        &self,
192        resource_type: &str,
193        id: &str,
194        patch_request: &Value,
195        expected_version: &RawVersion,
196        context: &RequestContext,
197    ) -> impl Future<Output = Result<ConditionalResult<VersionedResource>, Self::Error>> + Send
198    where
199        Self: Sync,
200    {
201        async move {
202            // Get current resource
203            match self.get_resource(resource_type, id, context).await? {
204                Some(current_resource) => {
205                    // Create versioned resource to get current version
206                    let versioned_current = current_resource;
207                    let current_version = versioned_current.version();
208
209                    // Check if versions match
210                    if current_version != expected_version {
211                        return Ok(ConditionalResult::VersionMismatch(
212                            VersionConflict::standard_message(
213                                expected_version.clone(),
214                                current_version.clone(),
215                            ),
216                        ));
217                    }
218
219                    // Version matches, proceed with patch
220                    match self
221                        .patch_resource(resource_type, id, patch_request, None, context)
222                        .await
223                    {
224                        Ok(patched_resource) => {
225                            let versioned_result = patched_resource;
226                            Ok(ConditionalResult::Success(versioned_result))
227                        }
228                        Err(e) => Err(e),
229                    }
230                }
231                None => Ok(ConditionalResult::NotFound),
232            }
233        }
234    }
235
236    /// Get a resource with its version information.
237    ///
238    /// Retrieves a resource wrapped in a VersionedResource container that includes
239    /// both the resource data and its computed version for use in conditional operations.
240    ///
241    /// # Arguments
242    /// * `resource_type` - The type of resource to retrieve
243    /// * `id` - The unique identifier of the resource
244    /// * `context` - Request context containing tenant information
245    ///
246    /// # Returns
247    /// The versioned resource if found, None if not found
248    ///
249    /// # Example
250    /// ```rust,no_run
251    /// // ConditionalOperations provides get_versioned_resource for getting resources with version info
252    /// // if let Some(versioned_resource) = provider.get_versioned_resource("Users", "123", &context).await? {
253    /// //     let current_version = versioned_resource.version().clone();
254    /// //     // Use current_version for subsequent conditional operations
255    /// // }
256    /// ```
257    fn get_versioned_resource(
258        &self,
259        resource_type: &str,
260        id: &str,
261        context: &RequestContext,
262    ) -> impl Future<Output = Result<Option<VersionedResource>, Self::Error>> + Send
263    where
264        Self: Sync,
265    {
266        async move {
267            match self.get_resource(resource_type, id, context).await? {
268                Some(versioned_resource) => Ok(Some(versioned_resource)),
269                None => Ok(None),
270            }
271        }
272    }
273
274    /// Create a resource with version information.
275    ///
276    /// Creates a new resource and returns it wrapped in a VersionedResource container
277    /// with its initial computed version for immediate use in conditional operations.
278    ///
279    /// # Arguments
280    /// * `resource_type` - The type of resource to create
281    /// * `data` - The resource data as JSON
282    /// * `context` - Request context containing tenant information
283    ///
284    /// # Returns
285    /// The newly created versioned resource
286    ///
287    /// # Example
288    /// ```rust,no_run
289    /// use serde_json::json;
290    ///
291    /// let user_data = json!({"userName": "john.doe", "active": true});
292    /// // ConditionalOperations provides create_versioned_resource for creating resources with version info
293    /// // let versioned_user = provider.create_versioned_resource("Users", user_data, &context).await?;
294    /// // let initial_version = versioned_user.version().clone();
295    /// ```
296    fn create_versioned_resource(
297        &self,
298        resource_type: &str,
299        data: Value,
300        context: &RequestContext,
301    ) -> impl Future<Output = Result<VersionedResource, Self::Error>> + Send
302    where
303        Self: Sync,
304    {
305        async move {
306            let versioned_resource = self.create_resource(resource_type, data, context).await?;
307            Ok(versioned_resource)
308        }
309    }
310
311    /// Check if a resource version matches the expected version.
312    ///
313    /// Utility method for comparing resource versions without performing operations,
314    /// useful for validation or pre-flight checks.
315    ///
316    /// # Arguments
317    /// * `resource_type` - The type of resource to check
318    /// * `id` - The unique identifier of the resource
319    /// * `expected_version` - The version to compare against
320    /// * `context` - Request context containing tenant information
321    ///
322    /// # Returns
323    /// * `Some(true)` - Resource exists and version matches
324    /// * `Some(false)` - Resource exists but version doesn't match
325    /// * `None` - Resource doesn't exist
326    fn check_resource_version(
327        &self,
328        resource_type: &str,
329        id: &str,
330        expected_version: &RawVersion,
331        context: &RequestContext,
332    ) -> impl Future<Output = Result<Option<bool>, Self::Error>> + Send
333    where
334        Self: Sync,
335    {
336        async move {
337            match self.get_resource(resource_type, id, context).await? {
338                Some(versioned_resource) => {
339                    Ok(Some(versioned_resource.version() == expected_version))
340                }
341                None => Ok(None),
342            }
343        }
344    }
345
346    /// Get the current version of a resource without retrieving the full resource.
347    ///
348    /// Optimized method for retrieving just the version information, useful for
349    /// version checks without the overhead of full resource retrieval.
350    ///
351    /// # Arguments
352    /// * `resource_type` - The type of resource to check
353    /// * `id` - The unique identifier of the resource
354    /// * `context` - Request context containing tenant information
355    ///
356    /// # Returns
357    /// The current version if the resource exists, None if not found
358    ///
359    /// # Default Implementation
360    /// The default implementation retrieves the full resource and computes the version.
361    /// Implementers may override this for more efficient version-only retrieval.
362    fn get_resource_version(
363        &self,
364        resource_type: &str,
365        id: &str,
366        context: &RequestContext,
367    ) -> impl Future<Output = Result<Option<RawVersion>, Self::Error>> + Send
368    where
369        Self: Sync,
370    {
371        async move {
372            match self.get_resource(resource_type, id, context).await? {
373                Some(versioned_resource) => Ok(Some(versioned_resource.version().clone())),
374                None => Ok(None),
375            }
376        }
377    }
378
379    /// Validate that a version is in the expected format.
380    ///
381    /// Checks that a version string or RawVersion follows expected patterns,
382    /// useful for input validation before conditional operations.
383    ///
384    /// # Arguments
385    /// * `version` - The version to validate
386    ///
387    /// # Returns
388    /// `true` if the version format is acceptable
389    fn is_valid_version(&self, version: &RawVersion) -> bool {
390        // Basic validation - version should not be empty
391        !version.as_str().trim().is_empty()
392    }
393
394    /// Create a version conflict error with standard messaging.
395    ///
396    /// Helper method for creating consistent version conflict errors across
397    /// conditional operations.
398    ///
399    /// # Arguments
400    /// * `expected` - The version the client expected
401    /// * `current` - The actual current version on the server
402    /// * `resource_info` - Optional additional context about the resource
403    ///
404    /// # Returns
405    /// A VersionConflict with appropriate error messaging
406    fn create_version_conflict(
407        &self,
408        expected: RawVersion,
409        current: RawVersion,
410        resource_info: Option<&str>,
411    ) -> VersionConflict {
412        let message = match resource_info {
413            Some(info) => format!(
414                "Resource {} was modified by another client. Expected version {}, current version {}",
415                info,
416                expected.as_str(),
417                current.as_str()
418            ),
419            None => format!(
420                "Resource was modified by another client. Expected version {}, current version {}",
421                expected.as_str(),
422                current.as_str()
423            ),
424        };
425        VersionConflict::new(expected, current, message)
426    }
427}
428
429/// Default implementation for any ResourceProvider
430impl<T: ResourceProvider> ConditionalOperations for T {}