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    /// Parse a version from a raw version string (MCP-native).
251    ///
252    /// This method accepts raw version strings directly without HTTP ETag formatting,
253    /// making it suitable for JSON-RPC protocols like MCP where HTTP semantics
254    /// are not applicable.
255    ///
256    /// # Arguments
257    /// * `version_str` - The raw version string (e.g., "abc123def")
258    ///
259    /// # Returns
260    /// The parsed version or an error if the version string is invalid
261    ///
262    /// # Examples
263    /// ```rust
264    /// use scim_server::resource::version::ScimVersion;
265    ///
266    /// let version = ScimVersion::parse_raw("abc123def").unwrap();
267    /// ```
268    pub fn parse_raw(version_str: &str) -> Result<Self, VersionError> {
269        let trimmed = version_str.trim();
270
271        if trimmed.is_empty() {
272            return Err(VersionError::ParseError("Version string cannot be empty".to_string()));
273        }
274
275        Ok(Self {
276            opaque: trimmed.to_string()
277        })
278    }
279
280    /// Convert version to HTTP ETag header value.
281    ///
282    /// This generates a weak HTTP ETag header value that can be used in conditional
283    /// HTTP requests. SCIM resources use weak ETags since they represent semantic
284    /// equivalence rather than byte-for-byte identity. The returned value includes
285    /// the W/ prefix and surrounding quotes required by RFC 7232.
286    ///
287    /// # Examples
288    /// ```rust
289    /// use scim_server::resource::version::ScimVersion;
290    ///
291    /// let version = ScimVersion::from_hash("12345");
292    /// let etag = version.to_http_header();
293    /// assert_eq!(etag, "W/\"12345\"");
294    /// ```
295    pub fn to_http_header(&self) -> String {
296        format!("W/\"{}\"", self.opaque)
297    }
298
299    /// Check if this version matches another version.
300    ///
301    /// This is used for conditional operations to determine if the expected
302    /// version matches the current version of a resource.
303    ///
304    /// # Arguments
305    /// * `other` - The version to compare against
306    ///
307    /// # Examples
308    /// ```rust
309    /// use scim_server::resource::version::ScimVersion;
310    ///
311    /// let v1 = ScimVersion::from_hash("123");
312    /// let v2 = ScimVersion::from_hash("123");
313    /// let v3 = ScimVersion::from_hash("456");
314    ///
315    /// assert!(v1.matches(&v2));
316    /// assert!(!v1.matches(&v3));
317    /// ```
318    pub fn matches(&self, other: &ScimVersion) -> bool {
319        self.opaque == other.opaque
320    }
321
322    /// Get the opaque version string.
323    ///
324    /// This is primarily for internal use and debugging. The opaque string
325    /// should not be relied upon for any business logic.
326    pub fn as_str(&self) -> &str {
327        &self.opaque
328    }
329}
330
331impl fmt::Display for ScimVersion {
332    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
333        write!(f, "{}", self.opaque)
334    }
335}
336
337/// Result type for conditional SCIM operations.
338///
339/// Represents the outcome of a conditional operation that depends on
340/// resource versioning. This allows providers to indicate whether
341/// an operation succeeded, failed due to a version mismatch, or
342/// failed because the resource was not found.
343///
344/// # Examples
345///
346/// ```rust
347/// use scim_server::resource::version::{ConditionalResult, ScimVersion, VersionConflict};
348/// use serde_json::json;
349///
350/// // Successful operation
351/// let success = ConditionalResult::Success(json!({"id": "123"}));
352///
353/// // Version mismatch
354/// let expected = ScimVersion::from_hash("1");
355/// let current = ScimVersion::from_hash("2");
356/// let conflict: ConditionalResult<serde_json::Value> = ConditionalResult::VersionMismatch(VersionConflict {
357///     expected,
358///     current,
359///     message: "Resource was modified by another client".to_string(),
360/// });
361///
362/// // Resource not found
363/// let not_found: ConditionalResult<serde_json::Value> = ConditionalResult::NotFound;
364/// ```
365#[derive(Debug, Clone, PartialEq)]
366pub enum ConditionalResult<T> {
367    /// Operation completed successfully
368    Success(T),
369
370    /// Operation failed due to version mismatch
371    VersionMismatch(VersionConflict),
372
373    /// Operation failed because the resource was not found
374    NotFound,
375}
376
377impl<T> ConditionalResult<T> {
378    /// Check if the result represents a successful operation.
379    pub fn is_success(&self) -> bool {
380        matches!(self, ConditionalResult::Success(_))
381    }
382
383    /// Check if the result represents a version mismatch.
384    pub fn is_version_mismatch(&self) -> bool {
385        matches!(self, ConditionalResult::VersionMismatch(_))
386    }
387
388    /// Check if the result represents a not found error.
389    pub fn is_not_found(&self) -> bool {
390        matches!(self, ConditionalResult::NotFound)
391    }
392
393    /// Extract the success value, if present.
394    pub fn into_success(self) -> Option<T> {
395        match self {
396            ConditionalResult::Success(value) => Some(value),
397            _ => None,
398        }
399    }
400
401    /// Extract the version conflict, if present.
402    pub fn into_version_conflict(self) -> Option<VersionConflict> {
403        match self {
404            ConditionalResult::VersionMismatch(conflict) => Some(conflict),
405            _ => None,
406        }
407    }
408
409    /// Map the success value to a different type.
410    pub fn map<U, F>(self, f: F) -> ConditionalResult<U>
411    where
412        F: FnOnce(T) -> U,
413    {
414        match self {
415            ConditionalResult::Success(value) => ConditionalResult::Success(f(value)),
416            ConditionalResult::VersionMismatch(conflict) => {
417                ConditionalResult::VersionMismatch(conflict)
418            }
419            ConditionalResult::NotFound => ConditionalResult::NotFound,
420        }
421    }
422}
423
424/// Details about a version conflict during a conditional operation.
425///
426/// Provides information about the expected version (from the client)
427/// and the current version (from the server), along with a human-readable
428/// error message.
429///
430/// # Examples
431///
432/// ```rust
433/// use scim_server::resource::version::{VersionConflict, ScimVersion};
434///
435/// let conflict = VersionConflict {
436///     expected: ScimVersion::from_hash("1"),
437///     current: ScimVersion::from_hash("2"),
438///     message: "Resource was modified by another client".to_string(),
439/// };
440/// ```
441#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
442pub struct VersionConflict {
443    /// The version that was expected by the client
444    pub expected: ScimVersion,
445
446    /// The current version of the resource on the server
447    pub current: ScimVersion,
448
449    /// Human-readable error message describing the conflict
450    pub message: String,
451}
452
453impl VersionConflict {
454    /// Create a new version conflict.
455    ///
456    /// # Arguments
457    /// * `expected` - The version expected by the client
458    /// * `current` - The current version on the server
459    /// * `message` - Human-readable error message
460    pub fn new(expected: ScimVersion, current: ScimVersion, message: impl Into<String>) -> Self {
461        Self {
462            expected,
463            current,
464            message: message.into(),
465        }
466    }
467
468    /// Create a standard version conflict message.
469    ///
470    /// # Arguments
471    /// * `expected` - The version expected by the client
472    /// * `current` - The current version on the server
473    pub fn standard_message(expected: ScimVersion, current: ScimVersion) -> Self {
474        Self::new(
475            expected,
476            current,
477            "Resource was modified by another client. Please refresh and try again.",
478        )
479    }
480}
481
482impl fmt::Display for VersionConflict {
483    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
484        write!(
485            f,
486            "Version conflict: expected '{}', found '{}'. {}",
487            self.expected, self.current, self.message
488        )
489    }
490}
491
492impl std::error::Error for VersionConflict {}
493
494/// Errors that can occur during version operations.
495#[derive(Debug, Error, Clone, PartialEq)]
496pub enum VersionError {
497    /// Invalid ETag format provided
498    #[error("Invalid ETag format: {0}")]
499    InvalidEtagFormat(String),
500
501    /// Version parsing failed
502    #[error("Failed to parse version: {0}")]
503    ParseError(String),
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    #[test]
511    fn test_version_from_content() {
512        let content = br#"{"id":"123","userName":"john.doe"}"#;
513        let version = ScimVersion::from_content(content);
514
515        // Version should be deterministic
516        let version2 = ScimVersion::from_content(content);
517        assert_eq!(version, version2);
518
519        // Different content should produce different versions
520        let different_content = br#"{"id":"123","userName":"jane.doe"}"#;
521        let different_version = ScimVersion::from_content(different_content);
522        assert_ne!(version, different_version);
523    }
524
525    #[test]
526    fn test_version_from_hash() {
527        let hash_string = "abc123def456";
528        let version = ScimVersion::from_hash(hash_string);
529        assert_eq!(version.as_str(), hash_string);
530        assert_eq!(version.to_http_header(), "W/\"abc123def456\"");
531
532        // Test with different hash strings
533        let version2 = ScimVersion::from_hash("different123");
534        assert_ne!(version, version2);
535    }
536
537    #[test]
538    fn test_version_parse_http_header() {
539        // Strong ETag
540        let version = ScimVersion::parse_http_header("\"abc123\"").unwrap();
541        assert_eq!(version.as_str(), "abc123");
542
543        // Weak ETag
544        let weak_version = ScimVersion::parse_http_header("W/\"abc123\"").unwrap();
545        assert_eq!(weak_version.as_str(), "abc123");
546
547        // Invalid formats
548        assert!(ScimVersion::parse_http_header("abc123").is_err());
549        assert!(ScimVersion::parse_http_header("\"\"").is_err());
550        assert!(ScimVersion::parse_http_header("").is_err());
551    }
552
553    #[test]
554    fn test_version_parse_raw() {
555        // Valid raw version
556        let version = ScimVersion::parse_raw("abc123def").unwrap();
557        assert_eq!(version.as_str(), "abc123def");
558
559        // Whitespace handling
560        let trimmed_version = ScimVersion::parse_raw("  xyz789  ").unwrap();
561        assert_eq!(trimmed_version.as_str(), "xyz789");
562
563        // Empty string should fail
564        assert!(ScimVersion::parse_raw("").is_err());
565        assert!(ScimVersion::parse_raw("   ").is_err());
566
567        // Compare with HTTP header parsing - raw should be simpler
568        let raw_version = ScimVersion::parse_raw("test123").unwrap();
569        let http_version = ScimVersion::parse_http_header("W/\"test123\"").unwrap();
570        assert!(raw_version.matches(&http_version));
571    }
572
573    #[test]
574    fn test_version_matches() {
575        let content = br#"{"id":"123","data":"test"}"#;
576        let v1 = ScimVersion::from_content(content);
577        let v2 = ScimVersion::from_content(content);
578        let v3 = ScimVersion::from_content(br#"{"id":"456","data":"test"}"#);
579
580        assert!(v1.matches(&v2));
581        assert!(!v1.matches(&v3));
582    }
583
584    #[test]
585    fn test_version_round_trip() {
586        let content = br#"{"id":"test","version":"round-trip"}"#;
587        let original = ScimVersion::from_content(content);
588        let etag = original.to_http_header();
589        let parsed = ScimVersion::parse_http_header(&etag).unwrap();
590
591        assert_eq!(original, parsed);
592    }
593
594    #[test]
595    fn test_conditional_result() {
596        let success: ConditionalResult<i32> = ConditionalResult::Success(42);
597        assert!(success.is_success());
598        assert_eq!(success.into_success(), Some(42));
599
600        let conflict = ConditionalResult::<i32>::VersionMismatch(VersionConflict::new(
601            ScimVersion::from_hash("version1"),
602            ScimVersion::from_hash("version2"),
603            "test conflict",
604        ));
605        assert!(conflict.is_version_mismatch());
606
607        let not_found: ConditionalResult<i32> = ConditionalResult::NotFound;
608        assert!(not_found.is_not_found());
609    }
610
611    #[test]
612    fn test_conditional_result_map() {
613        let success: ConditionalResult<i32> = ConditionalResult::Success(42);
614        let mapped = success.map(|x| x.to_string());
615        assert_eq!(mapped.into_success(), Some("42".to_string()));
616    }
617
618    #[test]
619    fn test_version_conflict() {
620        let conflict = VersionConflict::standard_message(
621            ScimVersion::from_hash("version1"),
622            ScimVersion::from_hash("version2"),
623        );
624
625        assert_eq!(conflict.expected.as_str(), "version1");
626        assert_eq!(conflict.current.as_str(), "version2");
627        assert!(!conflict.message.is_empty());
628    }
629
630    #[test]
631    fn test_version_conflict_display() {
632        let conflict = VersionConflict::new(
633            ScimVersion::from_hash("old-hash"),
634            ScimVersion::from_hash("new-hash"),
635            "Custom message",
636        );
637
638        let display = format!("{}", conflict);
639        assert!(display.contains("old-hash"));
640        assert!(display.contains("new-hash"));
641        assert!(display.contains("Custom message"));
642    }
643
644    #[test]
645    fn test_version_serialization() {
646        let content = br#"{"test":"serialization"}"#;
647        let version = ScimVersion::from_content(content);
648
649        // Test JSON serialization
650        let json = serde_json::to_string(&version).unwrap();
651        let deserialized: ScimVersion = serde_json::from_str(&json).unwrap();
652
653        assert_eq!(version, deserialized);
654    }
655
656    #[test]
657    fn test_version_conflict_serialization() {
658        let conflict = VersionConflict::new(
659            ScimVersion::from_hash("hash-v1"),
660            ScimVersion::from_hash("hash-v2"),
661            "Serialization test conflict",
662        );
663
664        // Test JSON serialization
665        let json = serde_json::to_string(&conflict).unwrap();
666        let deserialized: VersionConflict = serde_json::from_str(&json).unwrap();
667
668        assert_eq!(conflict, deserialized);
669    }
670}