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}