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}