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//! # Type-Safe Format Management
15//!
16//! This module uses phantom types to distinguish between HTTP ETag format and raw
17//! internal format at compile time, preventing format confusion:
18//!
19//! * [`HttpVersion`] - HTTP ETag format ("W/\"abc123\"")
20//! * [`RawVersion`] - Internal raw format ("abc123")
21//! * [`ConditionalResult`] - Result type for conditional operations
22//! * [`VersionConflict`] - Error details for version mismatches
23//!
24//! # Basic Usage
25//!
26//! ```rust
27//! use scim_server::resource::version::{RawVersion, HttpVersion};
28//!
29//! // Create version from hash string (for provider-specific versioning)
30//! let raw_version = RawVersion::from_hash("db-sequence-123");
31//!
32//! // Create version from content hash (automatic versioning)
33//! let resource_json = br#"{"id":"123","userName":"john.doe","active":true}"#;
34//! let content_version = RawVersion::from_content(resource_json);
35//!
36//! // Parse from HTTP weak ETag header (client-provided versions)
37//! let etag_version: HttpVersion = "W/\"abc123def\"".parse().unwrap();
38//!
39//! // Convert to HTTP weak ETag header (for responses)
40//! let etag_header = HttpVersion::from(raw_version.clone()).to_string(); // Returns: "W/\"abc123def\""
41//!
42//! // Check version equality (works across formats)
43//! let matches = raw_version == etag_version;
44//! ```
45//!
46//! # Format Conversions
47//!
48//! ```rust
49//! use scim_server::resource::version::{RawVersion, HttpVersion};
50//!
51//! // Raw to HTTP format
52//! let raw_version = RawVersion::from_hash("abc123");
53//! let http_version = HttpVersion::from(raw_version);
54//!
55//! // HTTP to Raw format
56//! let http_version: HttpVersion = "W/\"xyz789\"".parse().unwrap();
57//! let raw_version = RawVersion::from(http_version);
58//!
59//! // Direct string parsing
60//! let raw_from_str: RawVersion = "abc123".parse().unwrap();
61//! let http_from_str: HttpVersion = "\"xyz789\"".parse().unwrap();
62//! ```
63//!
64//! # Conditional Operations
65//!
66//! ```rust,no_run
67//! use scim_server::resource::version::{ConditionalResult, RawVersion, HttpVersion};
68//! use serde_json::json;
69//!
70//! // Version types provide type-safe version handling for conditional operations
71//! let expected_version = RawVersion::from_hash("current-version");
72//! let http_version: HttpVersion = expected_version.clone().into();
73//!
74//! // ConditionalResult enum provides type-safe results:
75//! // - Success(resource) - Operation succeeded
76//! // - VersionMismatch(conflict) - Resource was modified by another client
77//! // - NotFound - Resource doesn't exist
78//!
79//! let update_data = json!({"userName": "updated.name", "active": false});
80//! // Used with ConditionalOperations trait for optimistic locking
81//! ```
82
83use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
84use serde::{Deserialize, Deserializer, Serialize, Serializer};
85use sha2::{Digest, Sha256};
86use std::{fmt, marker::PhantomData, str::FromStr};
87use thiserror::Error;
88
89// Phantom type markers for format distinction
90#[derive(Debug, Clone, Copy)]
91pub struct Http;
92
93#[derive(Debug, Clone, Copy)]
94pub struct Raw;
95
96/// Opaque version identifier for SCIM resources with compile-time format safety.
97///
98/// This type uses phantom types to distinguish between HTTP ETag format and raw
99/// internal format at compile time, preventing format confusion and runtime errors.
100/// The internal representation remains opaque to prevent direct manipulation.
101///
102/// Versions can be created from:
103/// - Provider-specific identifiers (database sequence numbers, timestamps, etc.)
104/// - Content hashes (for stateless version generation)
105/// - String parsing with automatic format detection
106///
107/// # Type Safety
108///
109/// The phantom type parameter prevents mixing formats accidentally:
110/// ```compile_fail
111/// use scim_server::resource::version::{RawVersion, HttpVersion};
112///
113/// let raw_version = RawVersion::from_hash("123");
114/// let http_version: HttpVersion = "W/\"456\"".parse().unwrap();
115///
116/// // This won't compile - cannot pass HttpVersion where RawVersion expected
117/// some_function_expecting_raw(http_version);
118/// ```
119///
120/// # Examples
121///
122/// ```rust
123/// use scim_server::resource::version::{RawVersion, HttpVersion};
124///
125/// // From hash string (always produces Raw format)
126/// let raw_version = RawVersion::from_hash("12345");
127///
128/// // From content hash (always produces Raw format)
129/// let content = br#"{"id":"123","name":"John Doe"}"#;
130/// let hash_version = RawVersion::from_content(content);
131///
132/// // Parse from strings with format detection
133/// let raw_parsed: RawVersion = "abc123def".parse().unwrap();
134/// let http_parsed: HttpVersion = "\"abc123def\"".parse().unwrap();
135/// ```
136#[derive(Debug, Clone, Eq, Hash)]
137pub struct ScimVersion<Format> {
138    /// Opaque version identifier
139    opaque: String,
140    /// Phantom type marker for compile-time format distinction
141    #[allow(dead_code)]
142    _format: PhantomData<Format>,
143}
144
145/// Type alias for HTTP ETag format versions ("W/\"abc123\"")
146pub type HttpVersion = ScimVersion<Http>;
147
148/// Type alias for raw internal format versions ("abc123")
149pub type RawVersion = ScimVersion<Raw>;
150
151// Core constructors (always produce Raw format as the canonical form)
152impl<Format> ScimVersion<Format> {
153    /// Create a version from resource content.
154    ///
155    /// This generates a deterministic hash-based version from the resource content,
156    /// ensuring universal compatibility across all provider implementations.
157    /// The version is based on the full resource content including all fields.
158    ///
159    /// Always produces a [`RawVersion`] as content hashing creates canonical versions.
160    ///
161    /// # Arguments
162    /// * `content` - The complete resource content as bytes
163    ///
164    /// # Examples
165    /// ```rust
166    /// use scim_server::resource::version::RawVersion;
167    ///
168    /// let resource_json = br#"{"id":"123","userName":"john.doe"}"#;
169    /// let version = RawVersion::from_content(resource_json);
170    /// ```
171    pub fn from_content(content: &[u8]) -> RawVersion {
172        let mut hasher = Sha256::new();
173        hasher.update(content);
174        let hash = hasher.finalize();
175        let encoded = BASE64.encode(&hash[..8]); // Use first 8 bytes for shorter ETags
176
177        ScimVersion {
178            opaque: encoded,
179            _format: PhantomData,
180        }
181    }
182
183    /// Create a version from a pre-computed hash string.
184    ///
185    /// This is useful for provider-specific versioning schemes such as database
186    /// sequence numbers, timestamps, or UUIDs. The provider can use any string
187    /// as a version identifier.
188    ///
189    /// Always produces a [`RawVersion`] as the canonical internal format.
190    ///
191    /// # Arguments
192    /// * `hash_string` - Provider-specific version identifier
193    ///
194    /// # Examples
195    /// ```rust
196    /// use scim_server::resource::version::RawVersion;
197    ///
198    /// // Database sequence number
199    /// let db_version = RawVersion::from_hash("seq_12345");
200    ///
201    /// // Timestamp-based version
202    /// let time_version = RawVersion::from_hash("1703123456789");
203    ///
204    /// // UUID-based version
205    /// let uuid_version = RawVersion::from_hash("550e8400-e29b-41d4-a716-446655440000");
206    /// ```
207    pub fn from_hash(hash_string: impl AsRef<str>) -> RawVersion {
208        ScimVersion {
209            opaque: hash_string.as_ref().to_string(),
210            _format: PhantomData,
211        }
212    }
213
214    /// Get the opaque version string.
215    ///
216    /// This is primarily for internal use and debugging. The opaque string
217    /// should not be relied upon for any business logic outside of equality comparisons.
218    pub fn as_str(&self) -> &str {
219        &self.opaque
220    }
221}
222
223// Display implementation for Raw format (simple string output)
224impl fmt::Display for ScimVersion<Raw> {
225    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226        write!(f, "{}", self.opaque)
227    }
228}
229
230// Display implementation for HTTP format (weak ETag format)
231impl fmt::Display for ScimVersion<Http> {
232    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233        write!(f, "W/\"{}\"", self.opaque)
234    }
235}
236
237// FromStr implementation for Raw format (direct string parsing)
238impl FromStr for ScimVersion<Raw> {
239    type Err = VersionError;
240
241    fn from_str(version_str: &str) -> Result<Self, Self::Err> {
242        let trimmed = version_str.trim();
243
244        if trimmed.is_empty() {
245            return Err(VersionError::ParseError(
246                "Version string cannot be empty".to_string(),
247            ));
248        }
249
250        Ok(ScimVersion {
251            opaque: trimmed.to_string(),
252            _format: PhantomData,
253        })
254    }
255}
256
257// FromStr implementation for HTTP format (ETag parsing)
258impl FromStr for ScimVersion<Http> {
259    type Err = VersionError;
260
261    fn from_str(etag_header: &str) -> Result<Self, Self::Err> {
262        let trimmed = etag_header.trim();
263
264        // Handle weak ETags by removing W/ prefix
265        let etag_value = if trimmed.starts_with("W/") {
266            &trimmed[2..]
267        } else {
268            trimmed
269        };
270
271        // Remove surrounding quotes
272        if etag_value.len() < 2 || !etag_value.starts_with('"') || !etag_value.ends_with('"') {
273            return Err(VersionError::InvalidEtagFormat(etag_header.to_string()));
274        }
275
276        let opaque = etag_value[1..etag_value.len() - 1].to_string();
277
278        if opaque.is_empty() {
279            return Err(VersionError::InvalidEtagFormat(etag_header.to_string()));
280        }
281
282        Ok(ScimVersion {
283            opaque,
284            _format: PhantomData,
285        })
286    }
287}
288
289// Bidirectional conversions through owned values
290impl From<ScimVersion<Raw>> for ScimVersion<Http> {
291    fn from(raw: ScimVersion<Raw>) -> Self {
292        ScimVersion {
293            opaque: raw.opaque,
294            _format: PhantomData,
295        }
296    }
297}
298
299impl From<ScimVersion<Http>> for ScimVersion<Raw> {
300    fn from(http: ScimVersion<Http>) -> Self {
301        ScimVersion {
302            opaque: http.opaque,
303            _format: PhantomData,
304        }
305    }
306}
307
308// Cross-format comparison (versions are equal if opaque strings match)
309impl<F1, F2> PartialEq<ScimVersion<F2>> for ScimVersion<F1> {
310    fn eq(&self, other: &ScimVersion<F2>) -> bool {
311        self.opaque == other.opaque
312    }
313}
314
315// Serde implementations that preserve the opaque string regardless of format
316impl<Format> Serialize for ScimVersion<Format> {
317    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
318    where
319        S: Serializer,
320    {
321        self.opaque.serialize(serializer)
322    }
323}
324
325impl<'de, Format> Deserialize<'de> for ScimVersion<Format> {
326    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
327    where
328        D: Deserializer<'de>,
329    {
330        let opaque = String::deserialize(deserializer)?;
331        Ok(ScimVersion {
332            opaque,
333            _format: PhantomData,
334        })
335    }
336}
337
338/// Result type for conditional SCIM operations.
339///
340/// Represents the outcome of a conditional operation that depends on
341/// resource versioning. This allows providers to indicate whether
342/// an operation succeeded, failed due to a version mismatch, or
343/// failed because the resource was not found.
344///
345/// # Examples
346///
347/// ```rust
348/// use scim_server::resource::version::{ConditionalResult, RawVersion, VersionConflict};
349/// use serde_json::json;
350///
351/// // Successful operation
352/// let success = ConditionalResult::Success(json!({"id": "123"}));
353///
354/// // Version mismatch
355/// let expected = RawVersion::from_hash("1");
356/// let current = RawVersion::from_hash("2");
357/// let conflict: ConditionalResult<serde_json::Value> = ConditionalResult::VersionMismatch(VersionConflict {
358///     expected: expected.into(),
359///     current: current.into(),
360///     message: "Resource was modified by another client".to_string(),
361/// });
362///
363/// // Resource not found
364/// let not_found: ConditionalResult<serde_json::Value> = ConditionalResult::NotFound;
365/// ```
366#[derive(Debug, Clone, PartialEq)]
367pub enum ConditionalResult<T> {
368    /// Operation completed successfully
369    Success(T),
370
371    /// Operation failed due to version mismatch
372    VersionMismatch(VersionConflict),
373
374    /// Operation failed because the resource was not found
375    NotFound,
376}
377
378impl<T> ConditionalResult<T> {
379    /// Check if the result represents a successful operation.
380    pub fn is_success(&self) -> bool {
381        matches!(self, ConditionalResult::Success(_))
382    }
383
384    /// Check if the result represents a version mismatch.
385    pub fn is_version_mismatch(&self) -> bool {
386        matches!(self, ConditionalResult::VersionMismatch(_))
387    }
388
389    /// Check if the result represents a not found error.
390    pub fn is_not_found(&self) -> bool {
391        matches!(self, ConditionalResult::NotFound)
392    }
393
394    /// Extract the success value, if present.
395    pub fn into_success(self) -> Option<T> {
396        match self {
397            ConditionalResult::Success(value) => Some(value),
398            _ => None,
399        }
400    }
401
402    /// Extract the version conflict, if present.
403    pub fn into_version_conflict(self) -> Option<VersionConflict> {
404        match self {
405            ConditionalResult::VersionMismatch(conflict) => Some(conflict),
406            _ => None,
407        }
408    }
409
410    /// Map the success value to a different type.
411    pub fn map<U, F>(self, f: F) -> ConditionalResult<U>
412    where
413        F: FnOnce(T) -> U,
414    {
415        match self {
416            ConditionalResult::Success(value) => ConditionalResult::Success(f(value)),
417            ConditionalResult::VersionMismatch(conflict) => {
418                ConditionalResult::VersionMismatch(conflict)
419            }
420            ConditionalResult::NotFound => ConditionalResult::NotFound,
421        }
422    }
423}
424
425/// Details about a version conflict during a conditional operation.
426///
427/// Provides information about the expected version (from the client)
428/// and the current version (from the server), along with a human-readable
429/// error message. Uses [`RawVersion`] internally for consistent storage
430/// and comparison.
431///
432/// # Examples
433///
434/// ```rust
435/// use scim_server::resource::version::{VersionConflict, RawVersion};
436///
437/// let expected = RawVersion::from_hash("1");
438/// let current = RawVersion::from_hash("2");
439/// let conflict = VersionConflict {
440///     expected,
441///     current,
442///     message: "Resource was modified by another client".to_string(),
443/// };
444/// ```
445#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
446pub struct VersionConflict {
447    /// The version that was expected by the client (raw format)
448    pub expected: RawVersion,
449
450    /// The current version of the resource on the server (raw format)
451    pub current: RawVersion,
452
453    /// Human-readable error message describing the conflict
454    pub message: String,
455}
456
457impl VersionConflict {
458    /// Create a new version conflict.
459    ///
460    /// Accepts versions in any format and converts to raw format for internal storage.
461    ///
462    /// # Arguments
463    /// * `expected` - The version expected by the client
464    /// * `current` - The current version on the server
465    /// * `message` - Human-readable error message
466    pub fn new<E, C>(expected: E, current: C, message: impl Into<String>) -> Self
467    where
468        E: Into<RawVersion>,
469        C: Into<RawVersion>,
470    {
471        Self {
472            expected: expected.into(),
473            current: current.into(),
474            message: message.into(),
475        }
476    }
477
478    /// Create a standard version conflict message.
479    ///
480    /// Accepts versions in any format and converts to raw format for internal storage.
481    ///
482    /// # Arguments
483    /// * `expected` - The version expected by the client
484    /// * `current` - The current version on the server
485    pub fn standard_message<E, C>(expected: E, current: C) -> Self
486    where
487        E: Into<RawVersion>,
488        C: Into<RawVersion>,
489    {
490        Self::new(
491            expected,
492            current,
493            "Resource was modified by another client. Please refresh and try again.",
494        )
495    }
496}
497
498impl fmt::Display for VersionConflict {
499    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
500        write!(
501            f,
502            "Version conflict: expected '{}', found '{}'. {}",
503            self.expected, self.current, self.message
504        )
505    }
506}
507
508impl std::error::Error for VersionConflict {}
509
510/// Errors that can occur during version operations.
511#[derive(Debug, Error, Clone, PartialEq)]
512pub enum VersionError {
513    /// Invalid ETag format provided
514    #[error("Invalid ETag format: {0}")]
515    InvalidEtagFormat(String),
516
517    /// Version parsing failed
518    #[error("Failed to parse version: {0}")]
519    ParseError(String),
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    #[test]
527    fn test_version_from_content() {
528        let content1 = b"test content";
529        let content2 = b"test content";
530        let content3 = b"different content";
531
532        let version1 = RawVersion::from_content(content1);
533        let version2 = RawVersion::from_content(content2);
534        let version3 = RawVersion::from_content(content3);
535
536        // Same content should produce same version
537        assert_eq!(version1, version2);
538        // Different content should produce different version
539        assert_ne!(version1, version3);
540    }
541
542    #[test]
543    fn test_version_from_hash() {
544        let version1 = RawVersion::from_hash("abc123def");
545        let version2 = RawVersion::from_hash("abc123def");
546        let version3 = RawVersion::from_hash("xyz789");
547
548        assert_eq!(version1, version2);
549        assert_ne!(version1, version3);
550        assert_eq!(version1.as_str(), "abc123def");
551    }
552
553    #[test]
554    fn test_http_version_parse() {
555        // Test weak ETag parsing
556        let version1: HttpVersion = "W/\"abc123\"".parse().unwrap();
557        assert_eq!(version1.as_str(), "abc123");
558
559        // Test strong ETag parsing
560        let version2: HttpVersion = "\"xyz789\"".parse().unwrap();
561        assert_eq!(version2.as_str(), "xyz789");
562
563        // Test invalid formats
564        assert!("invalid".parse::<HttpVersion>().is_err());
565        assert!("\"\"".parse::<HttpVersion>().is_err());
566        assert!("W/invalid".parse::<HttpVersion>().is_err());
567    }
568
569    #[test]
570    fn test_raw_version_parse() {
571        let version: RawVersion = "abc123def".parse().unwrap();
572        assert_eq!(version.as_str(), "abc123def");
573
574        // Test empty string fails
575        assert!("".parse::<RawVersion>().is_err());
576        assert!("   ".parse::<RawVersion>().is_err());
577    }
578
579    #[test]
580    fn test_format_display() {
581        let raw_version = RawVersion::from_hash("abc123");
582        let http_version = HttpVersion::from(raw_version.clone());
583
584        assert_eq!(raw_version.to_string(), "abc123");
585        assert_eq!(http_version.to_string(), "W/\"abc123\"");
586
587        // Cross-format equality is guaranteed by type system
588        assert_eq!(raw_version, http_version);
589    }
590
591    #[test]
592    fn test_conditional_result() {
593        let success: ConditionalResult<i32> = ConditionalResult::Success(42);
594        let not_found: ConditionalResult<i32> = ConditionalResult::NotFound;
595        let conflict: ConditionalResult<i32> =
596            ConditionalResult::VersionMismatch(VersionConflict::new(
597                RawVersion::from_hash("1"),
598                RawVersion::from_hash("2"),
599                "test conflict",
600            ));
601
602        assert!(success.is_success());
603        assert!(!success.is_version_mismatch());
604        assert!(!success.is_not_found());
605
606        assert!(!not_found.is_success());
607        assert!(!not_found.is_version_mismatch());
608        assert!(not_found.is_not_found());
609
610        assert!(!conflict.is_success());
611        assert!(conflict.is_version_mismatch());
612        assert!(!conflict.is_not_found());
613    }
614
615    #[test]
616    fn test_conditional_result_map() {
617        let success: ConditionalResult<i32> = ConditionalResult::Success(21);
618        let doubled = success.map(|x| x * 2);
619        assert_eq!(doubled.into_success(), Some(42));
620    }
621
622    #[test]
623    fn test_version_conflict() {
624        let expected = RawVersion::from_hash("1");
625        let current = RawVersion::from_hash("2");
626        let conflict = VersionConflict::new(expected.clone(), current.clone(), "test message");
627
628        assert_eq!(conflict.expected, expected);
629        assert_eq!(conflict.current, current);
630        assert_eq!(conflict.message, "test message");
631    }
632
633    #[test]
634    fn test_version_conflict_display() {
635        let conflict = VersionConflict::standard_message(
636            RawVersion::from_hash("old"),
637            RawVersion::from_hash("new"),
638        );
639        let display_str = format!("{}", conflict);
640        assert!(display_str.contains("expected 'old'"));
641        assert!(display_str.contains("found 'new'"));
642        assert!(display_str.contains("Resource was modified"));
643    }
644
645    #[test]
646    fn test_version_serialization() {
647        let version = RawVersion::from_hash("test123");
648        let json = serde_json::to_string(&version).unwrap();
649        assert_eq!(json, "\"test123\"");
650
651        let deserialized: RawVersion = serde_json::from_str(&json).unwrap();
652        assert_eq!(version, deserialized);
653    }
654
655    #[test]
656    fn test_version_conflict_serialization() {
657        let conflict = VersionConflict::new(
658            RawVersion::from_hash("1"),
659            RawVersion::from_hash("2"),
660            "test",
661        );
662
663        let json = serde_json::to_string(&conflict).unwrap();
664        let deserialized: VersionConflict = serde_json::from_str(&json).unwrap();
665        assert_eq!(conflict, deserialized);
666    }
667}