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}