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 {}