scim_server/resource/
version.rs

1//! Version control types for SCIM resources.
2//!
3//! This module provides types and functionality for handling resource versioning
4//! and conditional operations, enabling ETag-based concurrency control as specified
5//! in RFC 7644 (SCIM 2.0) and RFC 7232 (HTTP ETags).
6//!
7//! # ETag Concurrency Control
8//!
9//! The version system provides automatic optimistic concurrency control for SCIM
10//! resources, preventing lost updates when multiple clients modify the same resource
11//! simultaneously. All versions are computed deterministically from resource content
12//! using SHA-256 hashing.
13//!
14//! # Core Types
15//!
16//! * [`ScimVersion`] - Opaque version identifier for resources
17//! * [`ConditionalResult`] - Result type for conditional operations
18//! * [`VersionConflict`] - Error details for version mismatches
19//!
20//! # Basic Usage
21//!
22//! ```rust
23//! use scim_server::resource::version::{ScimVersion, ConditionalResult};
24//!
25//! // Create version from hash string (for provider-specific versioning)
26//! let version = ScimVersion::from_hash("db-sequence-123");
27//!
28//! // Create version from content hash (automatic versioning)
29//! let resource_json = br#"{"id":"123","userName":"john.doe","active":true}"#;
30//! let content_version = ScimVersion::from_content(resource_json);
31//!
32//! // Parse from HTTP weak ETag header (client-provided versions)
33//! let etag_version = ScimVersion::parse_http_header("W/\"abc123def\"").unwrap();
34//!
35//! // Convert to HTTP weak ETag header (for responses)
36//! let etag_header = version.to_http_header(); // Returns: "W/abc123def"
37//!
38//! // Check version equality (for conditional operations)
39//! let matches = version.matches(&etag_version);
40//! ```
41//!
42//! # Conditional Operations
43//!
44//! ```rust,no_run
45//! use scim_server::resource::version::{ConditionalResult, ScimVersion};
46//! use scim_server::resource::{ResourceProvider, RequestContext};
47//! use serde_json::json;
48//!
49//! # async fn example<P: ResourceProvider + Sync>(provider: &P) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
50//! let context = RequestContext::with_generated_id();
51//! let expected_version = ScimVersion::from_hash("current-version");
52//! let update_data = json!({"userName": "updated.name", "active": false});
53//!
54//! // Conditional update with version checking
55//! match provider.conditional_update("User", "123", update_data, &expected_version, &context).await? {
56//!     ConditionalResult::Success(versioned_resource) => {
57//!         println!("Update successful!");
58//!         println!("New weak ETag: {}", versioned_resource.version().to_http_header());
59//!     },
60//!     ConditionalResult::VersionMismatch(conflict) => {
61//!         println!("Version conflict detected!");
62//!         println!("Expected: {}", conflict.expected);
63//!         println!("Current: {}", conflict.current);
64//!         println!("Message: {}", conflict.message);
65//!         // Client should refresh and retry with current version
66//!     },
67//!     ConditionalResult::NotFound => {
68//!         println!("Resource not found");
69//!         // Handle missing resource scenario
70//!     }
71//! }
72//! # Ok(())
73//! # }
74//! ```
75//!
76//! # HTTP Integration
77//!
78//! The version system integrates seamlessly with HTTP weak ETags:
79//!
80//! ```rust
81//! use scim_server::resource::version::ScimVersion;
82//!
83//! // Server generates weak ETag for response
84//! let resource_data = br#"{"id":"123","userName":"alice","active":true}"#;
85//! let version = ScimVersion::from_content(resource_data);
86//! let etag_header = version.to_http_header(); // "W/xyz789abc"
87//!
88//! // Client provides weak ETag in subsequent request (If-Match header)
89//! let client_etag = "W/\"xyz789abc\"";
90//! let client_version = ScimVersion::parse_http_header(client_etag).unwrap();
91//!
92//! // Server validates version before operation
93//! if version.matches(&client_version) {
94//!     println!("Versions match - proceed with operation");
95//! } else {
96//!     println!("Version mismatch - return 412 Precondition Failed");
97//! }
98//! ```
99//!
100//! # Version Properties
101//!
102//! - **Deterministic**: Same content always produces the same version
103//! - **Content-Based**: Any change to resource data changes the version
104//! - **Collision-Resistant**: SHA-256 based hashing prevents accidental conflicts
105//! - **Compact**: Base64 encoded for efficient transmission
106//! - **Opaque**: Internal representation prevents manipulation
107//! - **HTTP Compatible**: Direct integration with weak ETag headers
108
109use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
110use serde::{Deserialize, Serialize};
111use sha2::{Digest, Sha256};
112use std::fmt;
113use thiserror::Error;
114
115/// Opaque version identifier for SCIM resources.
116///
117/// Represents a version of a resource that can be used for optimistic concurrency
118/// control. The internal representation is opaque to prevent direct manipulation
119/// and ensure version consistency across different provider implementations.
120///
121/// Versions can be created from:
122/// - Provider-specific identifiers (database sequence numbers, timestamps, etc.)
123/// - Content hashes (for stateless version generation)
124/// - HTTP ETag headers (for parsing client-provided versions)
125///
126/// # Examples
127///
128/// ```rust
129/// use scim_server::resource::version::ScimVersion;
130///
131/// // From hash string
132/// let version = ScimVersion::from_hash("12345");
133///
134/// // From content hash
135/// let content = br#"{"id":"123","name":"John Doe"}"#;
136/// let hash_version = ScimVersion::from_content(content);
137///
138/// // From HTTP ETag
139/// let etag_version = ScimVersion::parse_http_header("\"abc123def\"").unwrap();
140/// ```
141#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
142pub struct ScimVersion {
143    /// Opaque version identifier
144    opaque: String,
145}
146
147impl ScimVersion {
148    /// Create a version from resource content.
149    ///
150    /// This generates a deterministic hash-based version from the resource content,
151    /// ensuring universal compatibility across all provider implementations.
152    /// The version is based on the full resource content including all fields.
153    ///
154    /// # Arguments
155    /// * `content` - The complete resource content as bytes
156    ///
157    /// # Examples
158    /// ```rust
159    /// use scim_server::resource::version::ScimVersion;
160    ///
161    /// let resource_json = br#"{"id":"123","userName":"john.doe"}"#;
162    /// let version = ScimVersion::from_content(resource_json);
163    /// ```
164    pub fn from_content(content: &[u8]) -> Self {
165        let mut hasher = Sha256::new();
166        hasher.update(content);
167        let hash = hasher.finalize();
168        let encoded = BASE64.encode(&hash[..8]); // Use first 8 bytes for shorter ETags
169        Self { opaque: encoded }
170    }
171
172    /// Create a version from a pre-computed hash string.
173    ///
174    /// This is useful for provider-specific versioning schemes such as database
175    /// sequence numbers, timestamps, or UUIDs. The provider can use any string
176    /// as a version identifier.
177    ///
178    /// # Arguments
179    /// * `hash_string` - Provider-specific version identifier
180    ///
181    /// # Examples
182    /// ```rust
183    /// use scim_server::resource::version::ScimVersion;
184    ///
185    /// // Database sequence number
186    /// let db_version = ScimVersion::from_hash("seq_12345");
187    ///
188    /// // Timestamp-based version
189    /// let time_version = ScimVersion::from_hash("1703123456789");
190    ///
191    /// // UUID-based version
192    /// let uuid_version = ScimVersion::from_hash("550e8400-e29b-41d4-a716-446655440000");
193    /// ```
194    ///
195    /// # Examples
196    /// ```rust
197    /// use scim_server::resource::version::ScimVersion;
198    ///
199    /// let version = ScimVersion::from_hash("abc123def");
200    /// ```
201    pub fn from_hash(hash_string: impl AsRef<str>) -> Self {
202        Self {
203            opaque: hash_string.as_ref().to_string(),
204        }
205    }
206
207    /// Parse a version from an HTTP ETag header value.
208    ///
209    /// Accepts both weak and strong ETags as defined in RFC 7232.
210    /// Weak ETags (prefixed with "W/") are treated the same as strong ETags
211    /// for SCIM resource versioning purposes.
212    ///
213    /// # Arguments
214    /// * `etag_header` - The ETag header value (e.g., "\"abc123\"" or "W/\"abc123\"")
215    ///
216    /// # Returns
217    /// The parsed version or an error if the ETag format is invalid
218    ///
219    /// # Examples
220    /// ```rust
221    /// use scim_server::resource::version::ScimVersion;
222    ///
223    /// let version = ScimVersion::parse_http_header("\"abc123\"").unwrap();
224    /// let weak_version = ScimVersion::parse_http_header("W/\"abc123\"").unwrap();
225    /// ```
226    pub fn parse_http_header(etag_header: &str) -> Result<Self, VersionError> {
227        let trimmed = etag_header.trim();
228
229        // Handle weak ETags by removing W/ prefix
230        let etag_value = if trimmed.starts_with("W/") {
231            &trimmed[2..]
232        } else {
233            trimmed
234        };
235
236        // Remove surrounding quotes
237        if etag_value.len() < 2 || !etag_value.starts_with('"') || !etag_value.ends_with('"') {
238            return Err(VersionError::InvalidEtagFormat(etag_header.to_string()));
239        }
240
241        let opaque = etag_value[1..etag_value.len() - 1].to_string();
242
243        if opaque.is_empty() {
244            return Err(VersionError::InvalidEtagFormat(etag_header.to_string()));
245        }
246
247        Ok(Self { opaque })
248    }
249
250    /// Convert version to HTTP ETag header value.
251    ///
252    /// This generates a weak HTTP ETag header value that can be used in conditional
253    /// HTTP requests. SCIM resources use weak ETags since they represent semantic
254    /// equivalence rather than byte-for-byte identity. The returned value includes
255    /// the W/ prefix and surrounding quotes required by RFC 7232.
256    ///
257    /// # Examples
258    /// ```rust
259    /// use scim_server::resource::version::ScimVersion;
260    ///
261    /// let version = ScimVersion::from_hash("12345");
262    /// let etag = version.to_http_header();
263    /// assert_eq!(etag, "W/\"12345\"");
264    /// ```
265    pub fn to_http_header(&self) -> String {
266        format!("W/\"{}\"", self.opaque)
267    }
268
269    /// Check if this version matches another version.
270    ///
271    /// This is used for conditional operations to determine if the expected
272    /// version matches the current version of a resource.
273    ///
274    /// # Arguments
275    /// * `other` - The version to compare against
276    ///
277    /// # Examples
278    /// ```rust
279    /// use scim_server::resource::version::ScimVersion;
280    ///
281    /// let v1 = ScimVersion::from_hash("123");
282    /// let v2 = ScimVersion::from_hash("123");
283    /// let v3 = ScimVersion::from_hash("456");
284    ///
285    /// assert!(v1.matches(&v2));
286    /// assert!(!v1.matches(&v3));
287    /// ```
288    pub fn matches(&self, other: &ScimVersion) -> bool {
289        self.opaque == other.opaque
290    }
291
292    /// Get the opaque version string.
293    ///
294    /// This is primarily for internal use and debugging. The opaque string
295    /// should not be relied upon for any business logic.
296    pub fn as_str(&self) -> &str {
297        &self.opaque
298    }
299}
300
301impl fmt::Display for ScimVersion {
302    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
303        write!(f, "{}", self.opaque)
304    }
305}
306
307/// Result type for conditional SCIM operations.
308///
309/// Represents the outcome of a conditional operation that depends on
310/// resource versioning. This allows providers to indicate whether
311/// an operation succeeded, failed due to a version mismatch, or
312/// failed because the resource was not found.
313///
314/// # Examples
315///
316/// ```rust
317/// use scim_server::resource::version::{ConditionalResult, ScimVersion, VersionConflict};
318/// use serde_json::json;
319///
320/// // Successful operation
321/// let success = ConditionalResult::Success(json!({"id": "123"}));
322///
323/// // Version mismatch
324/// let expected = ScimVersion::from_hash("1");
325/// let current = ScimVersion::from_hash("2");
326/// let conflict: ConditionalResult<serde_json::Value> = ConditionalResult::VersionMismatch(VersionConflict {
327///     expected,
328///     current,
329///     message: "Resource was modified by another client".to_string(),
330/// });
331///
332/// // Resource not found
333/// let not_found: ConditionalResult<serde_json::Value> = ConditionalResult::NotFound;
334/// ```
335#[derive(Debug, Clone, PartialEq)]
336pub enum ConditionalResult<T> {
337    /// Operation completed successfully
338    Success(T),
339
340    /// Operation failed due to version mismatch
341    VersionMismatch(VersionConflict),
342
343    /// Operation failed because the resource was not found
344    NotFound,
345}
346
347impl<T> ConditionalResult<T> {
348    /// Check if the result represents a successful operation.
349    pub fn is_success(&self) -> bool {
350        matches!(self, ConditionalResult::Success(_))
351    }
352
353    /// Check if the result represents a version mismatch.
354    pub fn is_version_mismatch(&self) -> bool {
355        matches!(self, ConditionalResult::VersionMismatch(_))
356    }
357
358    /// Check if the result represents a not found error.
359    pub fn is_not_found(&self) -> bool {
360        matches!(self, ConditionalResult::NotFound)
361    }
362
363    /// Extract the success value, if present.
364    pub fn into_success(self) -> Option<T> {
365        match self {
366            ConditionalResult::Success(value) => Some(value),
367            _ => None,
368        }
369    }
370
371    /// Extract the version conflict, if present.
372    pub fn into_version_conflict(self) -> Option<VersionConflict> {
373        match self {
374            ConditionalResult::VersionMismatch(conflict) => Some(conflict),
375            _ => None,
376        }
377    }
378
379    /// Map the success value to a different type.
380    pub fn map<U, F>(self, f: F) -> ConditionalResult<U>
381    where
382        F: FnOnce(T) -> U,
383    {
384        match self {
385            ConditionalResult::Success(value) => ConditionalResult::Success(f(value)),
386            ConditionalResult::VersionMismatch(conflict) => {
387                ConditionalResult::VersionMismatch(conflict)
388            }
389            ConditionalResult::NotFound => ConditionalResult::NotFound,
390        }
391    }
392}
393
394/// Details about a version conflict during a conditional operation.
395///
396/// Provides information about the expected version (from the client)
397/// and the current version (from the server), along with a human-readable
398/// error message.
399///
400/// # Examples
401///
402/// ```rust
403/// use scim_server::resource::version::{VersionConflict, ScimVersion};
404///
405/// let conflict = VersionConflict {
406///     expected: ScimVersion::from_hash("1"),
407///     current: ScimVersion::from_hash("2"),
408///     message: "Resource was modified by another client".to_string(),
409/// };
410/// ```
411#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
412pub struct VersionConflict {
413    /// The version that was expected by the client
414    pub expected: ScimVersion,
415
416    /// The current version of the resource on the server
417    pub current: ScimVersion,
418
419    /// Human-readable error message describing the conflict
420    pub message: String,
421}
422
423impl VersionConflict {
424    /// Create a new version conflict.
425    ///
426    /// # Arguments
427    /// * `expected` - The version expected by the client
428    /// * `current` - The current version on the server
429    /// * `message` - Human-readable error message
430    pub fn new(expected: ScimVersion, current: ScimVersion, message: impl Into<String>) -> Self {
431        Self {
432            expected,
433            current,
434            message: message.into(),
435        }
436    }
437
438    /// Create a standard version conflict message.
439    ///
440    /// # Arguments
441    /// * `expected` - The version expected by the client
442    /// * `current` - The current version on the server
443    pub fn standard_message(expected: ScimVersion, current: ScimVersion) -> Self {
444        Self::new(
445            expected,
446            current,
447            "Resource was modified by another client. Please refresh and try again.",
448        )
449    }
450}
451
452impl fmt::Display for VersionConflict {
453    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
454        write!(
455            f,
456            "Version conflict: expected '{}', found '{}'. {}",
457            self.expected, self.current, self.message
458        )
459    }
460}
461
462impl std::error::Error for VersionConflict {}
463
464/// Errors that can occur during version operations.
465#[derive(Debug, Error, Clone, PartialEq)]
466pub enum VersionError {
467    /// Invalid ETag format provided
468    #[error("Invalid ETag format: {0}")]
469    InvalidEtagFormat(String),
470
471    /// Version parsing failed
472    #[error("Failed to parse version: {0}")]
473    ParseError(String),
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    #[test]
481    fn test_version_from_content() {
482        let content = br#"{"id":"123","userName":"john.doe"}"#;
483        let version = ScimVersion::from_content(content);
484
485        // Version should be deterministic
486        let version2 = ScimVersion::from_content(content);
487        assert_eq!(version, version2);
488
489        // Different content should produce different versions
490        let different_content = br#"{"id":"123","userName":"jane.doe"}"#;
491        let different_version = ScimVersion::from_content(different_content);
492        assert_ne!(version, different_version);
493    }
494
495    #[test]
496    fn test_version_from_hash() {
497        let hash_string = "abc123def456";
498        let version = ScimVersion::from_hash(hash_string);
499        assert_eq!(version.as_str(), hash_string);
500        assert_eq!(version.to_http_header(), "W/\"abc123def456\"");
501
502        // Test with different hash strings
503        let version2 = ScimVersion::from_hash("different123");
504        assert_ne!(version, version2);
505    }
506
507    #[test]
508    fn test_version_parse_http_header() {
509        // Strong ETag
510        let version = ScimVersion::parse_http_header("\"abc123\"").unwrap();
511        assert_eq!(version.as_str(), "abc123");
512
513        // Weak ETag
514        let weak_version = ScimVersion::parse_http_header("W/\"abc123\"").unwrap();
515        assert_eq!(weak_version.as_str(), "abc123");
516
517        // Invalid formats
518        assert!(ScimVersion::parse_http_header("abc123").is_err());
519        assert!(ScimVersion::parse_http_header("\"\"").is_err());
520        assert!(ScimVersion::parse_http_header("").is_err());
521    }
522
523    #[test]
524    fn test_version_matches() {
525        let content = br#"{"id":"123","data":"test"}"#;
526        let v1 = ScimVersion::from_content(content);
527        let v2 = ScimVersion::from_content(content);
528        let v3 = ScimVersion::from_content(br#"{"id":"456","data":"test"}"#);
529
530        assert!(v1.matches(&v2));
531        assert!(!v1.matches(&v3));
532    }
533
534    #[test]
535    fn test_version_round_trip() {
536        let content = br#"{"id":"test","version":"round-trip"}"#;
537        let original = ScimVersion::from_content(content);
538        let etag = original.to_http_header();
539        let parsed = ScimVersion::parse_http_header(&etag).unwrap();
540
541        assert_eq!(original, parsed);
542    }
543
544    #[test]
545    fn test_conditional_result() {
546        let success: ConditionalResult<i32> = ConditionalResult::Success(42);
547        assert!(success.is_success());
548        assert_eq!(success.into_success(), Some(42));
549
550        let conflict = ConditionalResult::<i32>::VersionMismatch(VersionConflict::new(
551            ScimVersion::from_hash("version1"),
552            ScimVersion::from_hash("version2"),
553            "test conflict",
554        ));
555        assert!(conflict.is_version_mismatch());
556
557        let not_found: ConditionalResult<i32> = ConditionalResult::NotFound;
558        assert!(not_found.is_not_found());
559    }
560
561    #[test]
562    fn test_conditional_result_map() {
563        let success: ConditionalResult<i32> = ConditionalResult::Success(42);
564        let mapped = success.map(|x| x.to_string());
565        assert_eq!(mapped.into_success(), Some("42".to_string()));
566    }
567
568    #[test]
569    fn test_version_conflict() {
570        let conflict = VersionConflict::standard_message(
571            ScimVersion::from_hash("version1"),
572            ScimVersion::from_hash("version2"),
573        );
574
575        assert_eq!(conflict.expected.as_str(), "version1");
576        assert_eq!(conflict.current.as_str(), "version2");
577        assert!(!conflict.message.is_empty());
578    }
579
580    #[test]
581    fn test_version_conflict_display() {
582        let conflict = VersionConflict::new(
583            ScimVersion::from_hash("old-hash"),
584            ScimVersion::from_hash("new-hash"),
585            "Custom message",
586        );
587
588        let display = format!("{}", conflict);
589        assert!(display.contains("old-hash"));
590        assert!(display.contains("new-hash"));
591        assert!(display.contains("Custom message"));
592    }
593
594    #[test]
595    fn test_version_serialization() {
596        let content = br#"{"test":"serialization"}"#;
597        let version = ScimVersion::from_content(content);
598
599        // Test JSON serialization
600        let json = serde_json::to_string(&version).unwrap();
601        let deserialized: ScimVersion = serde_json::from_str(&json).unwrap();
602
603        assert_eq!(version, deserialized);
604    }
605
606    #[test]
607    fn test_version_conflict_serialization() {
608        let conflict = VersionConflict::new(
609            ScimVersion::from_hash("hash-v1"),
610            ScimVersion::from_hash("hash-v2"),
611            "Serialization test conflict",
612        );
613
614        // Test JSON serialization
615        let json = serde_json::to_string(&conflict).unwrap();
616        let deserialized: VersionConflict = serde_json::from_str(&json).unwrap();
617
618        assert_eq!(conflict, deserialized);
619    }
620}